From 5bfb6fb6f5d236edccc49c5be3d2941f2c560988 Mon Sep 17 00:00:00 2001 From: Emt-lin <41323133+Emt-lin@users.noreply.github.com> Date: Fri, 16 Jan 2026 03:31:48 +0800 Subject: [PATCH] fix: Github Copilot add streaming support and improve robustness (#2113) - Add streaming response with SSE parsing (eventsource-parser) - Add Accept: text/event-stream header and content-type validation - Add empty output detection to prevent silent failures - Unify token expiry logic between getAuthState() and getValidCopilotToken() - Add proper resource cleanup with reader.cancel() - Add request cancellation handling in GitHubCopilotAuth component - Extract HTTP_STATUS_MESSAGES constant - Remove fallback mechanism for simpler error handling --- .../githubCopilot/GitHubCopilotChatModel.ts | 150 ++++- .../githubCopilot/GitHubCopilotProvider.ts | 602 +++++++++++++++--- .../v2/components/GitHubCopilotAuth.tsx | 64 +- 3 files changed, 698 insertions(+), 118 deletions(-) diff --git a/src/LLMProviders/githubCopilot/GitHubCopilotChatModel.ts b/src/LLMProviders/githubCopilot/GitHubCopilotChatModel.ts index ba4708f4..6f0561cc 100644 --- a/src/LLMProviders/githubCopilot/GitHubCopilotChatModel.ts +++ b/src/LLMProviders/githubCopilot/GitHubCopilotChatModel.ts @@ -2,8 +2,8 @@ import { BaseChatModel, type BaseChatModelParams, } from "@langchain/core/language_models/chat_models"; -import { AIMessage, type BaseMessage, type MessageContent } from "@langchain/core/messages"; -import { type ChatResult, ChatGeneration } from "@langchain/core/outputs"; +import { AIMessage, AIMessageChunk, type BaseMessage, type MessageContent } from "@langchain/core/messages"; +import { type ChatResult, ChatGeneration, ChatGenerationChunk } from "@langchain/core/outputs"; import { type CallbackManagerForLLMRun } from "@langchain/core/callbacks/manager"; import { GitHubCopilotProvider } from "./GitHubCopilotProvider"; import { extractTextFromChunk } from "@/utils"; @@ -13,6 +13,7 @@ const CHARS_PER_TOKEN = 4; export interface GitHubCopilotChatModelParams extends BaseChatModelParams { modelName: string; + streaming?: boolean; } /** @@ -24,11 +25,13 @@ export class GitHubCopilotChatModel extends BaseChatModel { private provider: GitHubCopilotProvider; modelName: string; + streaming: boolean; constructor(fields: GitHubCopilotChatModelParams) { super(fields); this.provider = GitHubCopilotProvider.getInstance(); this.modelName = fields.modelName; + this.streaming = fields.streaming ?? true; } _llmType(): string { @@ -57,6 +60,16 @@ export class GitHubCopilotChatModel extends BaseChatModel { } } + /** + * Convert LangChain messages to Copilot API format. + */ + private toCopilotMessages(messages: BaseMessage[]): Array<{ role: string; content: string }> { + return messages.map((m) => ({ + role: this.convertMessageType(m._getType()), + content: extractTextFromChunk(m.content), + })); + } + /** * Generate chat completion */ @@ -65,29 +78,146 @@ export class GitHubCopilotChatModel extends BaseChatModel { _options: this["ParsedCallOptions"], _runManager?: CallbackManagerForLLMRun ): Promise { - // Convert LangChain messages to OpenAI format - const chatMessages = messages.map((m) => ({ - role: this.convertMessageType(m._getType()), - content: extractTextFromChunk(m.content), - })); + const chatMessages = this.toCopilotMessages(messages); // Call Copilot API const response = await this.provider.sendChatMessage(chatMessages, this.modelName); - const content = response.choices?.[0]?.message?.content || ""; + const choice = response.choices?.[0]; + const content = choice?.message?.content || ""; + const finishReason = choice?.finish_reason; + + // Map token usage to camelCase format expected by the project + const tokenUsage = response.usage + ? { + promptTokens: response.usage.prompt_tokens, + completionTokens: response.usage.completion_tokens, + totalTokens: response.usage.total_tokens, + } + : undefined; + + // Build response_metadata for truncation detection and token usage extraction + const responseMetadata = { + finish_reason: finishReason, + tokenUsage, + model: response.model, + }; const generation: ChatGeneration = { text: content, - message: new AIMessage(content), + message: new AIMessage({ + content, + response_metadata: responseMetadata, + }), + generationInfo: { finish_reason: finishReason }, }; return { generations: [generation], llmOutput: { - tokenUsage: response.usage, + tokenUsage, }, }; } + /** + * Stream chat completion chunks. + * If streaming is disabled, yields a single chunk from _generate. + * If streaming fails, the error is propagated (no silent fallback). + */ + override async *_streamResponseChunks( + messages: BaseMessage[], + options: this["ParsedCallOptions"], + runManager?: CallbackManagerForLLMRun + ): AsyncGenerator { + // If streaming is disabled, use _generate and yield as single chunk + if (!this.streaming) { + const result = await this._generate(messages, options, runManager); + const generation = result.generations[0]; + if (!generation) return; + + const messageChunk = new AIMessageChunk({ + content: generation.text, + response_metadata: generation.message.response_metadata, + }); + + const generationChunk = new ChatGenerationChunk({ + message: messageChunk, + text: generation.text, + generationInfo: generation.generationInfo, + }); + + if (runManager && generation.text) { + await runManager.handleLLMNewToken(generation.text); + } + + yield generationChunk; + return; + } + + const chatMessages = this.toCopilotMessages(messages); + let didYieldChunk = false; + + // Stream directly, no fallback - errors are propagated to caller + for await (const chunk of this.provider.sendChatMessageStream( + chatMessages, + this.modelName, + options?.signal + )) { + const choice = chunk.choices?.[0]; + const content = choice?.delta?.content || ""; + + // Don't skip chunks with usage or finish_reason even if content is empty + const hasMetadata = choice?.finish_reason || chunk.usage || choice?.delta?.role; + if (!content && !hasMetadata) { + continue; + } + + // Build response_metadata for the chunk + const responseMetadata: Record = {}; + if (choice?.finish_reason) { + responseMetadata.finish_reason = choice.finish_reason; + } + if (choice?.delta?.role) { + responseMetadata.role = choice.delta.role; + } + if (chunk.usage) { + responseMetadata.tokenUsage = { + promptTokens: chunk.usage.prompt_tokens, + completionTokens: chunk.usage.completion_tokens, + totalTokens: chunk.usage.total_tokens, + }; + } + if (chunk.model) { + responseMetadata.model = chunk.model; + } + + const messageChunk = new AIMessageChunk({ + content, + response_metadata: Object.keys(responseMetadata).length > 0 ? responseMetadata : undefined, + }); + + const generationChunk = new ChatGenerationChunk({ + message: messageChunk, + text: content, + generationInfo: choice?.finish_reason ? { finish_reason: choice.finish_reason } : undefined, + }); + + // Notify run manager of new token + if (runManager && content) { + await runManager.handleLLMNewToken(content); + } + + didYieldChunk = true; + yield generationChunk; + } + + // Detect silent failures where streaming completed but produced no chunks at all. + // Avoid treating metadata-only streams as failures. + if (!didYieldChunk) { + throw new Error("GitHub Copilot streaming produced no chunks"); + } + } + /** * Simple token estimation based on character count */ diff --git a/src/LLMProviders/githubCopilot/GitHubCopilotProvider.ts b/src/LLMProviders/githubCopilot/GitHubCopilotProvider.ts index 545102ca..505ebd8f 100644 --- a/src/LLMProviders/githubCopilot/GitHubCopilotProvider.ts +++ b/src/LLMProviders/githubCopilot/GitHubCopilotProvider.ts @@ -1,7 +1,8 @@ -import { getSettings, updateSetting } from "@/settings/model"; +import { getSettings, setSettings } from "@/settings/model"; import { getDecryptedKey } from "@/encryptionService"; import { GitHubCopilotModelResponse } from "@/settings/providerModels"; -import { requestUrl } from "obsidian"; +import { requestUrl, type RequestUrlResponse } from "obsidian"; +import { createParser, type ParsedEvent, type ReconnectInterval } from "eventsource-parser"; /** * GitHub Copilot OAuth Client ID (from VSCode). @@ -24,6 +25,38 @@ const DEFAULT_TOKEN_EXPIRATION_MS = 60 * 60 * 1000; // Maximum refresh attempts before giving up const MAX_REFRESH_ATTEMPTS = 3; +// Common HTTP status error messages for Copilot API +const HTTP_STATUS_MESSAGES: Record = { + 401: "Authentication failed - token may be expired", + 403: "Access denied - check your Copilot subscription", + 429: "Rate limited - please wait before retrying", +}; + +interface GitHubDeviceCodeApiResponse { + device_code?: string; + user_code?: string; + verification_uri?: string; + verification_uri_complete?: string; + expires_in?: number; + interval?: number; + error?: string; + error_description?: string; +} + +interface GitHubOAuthTokenResponse { + access_token?: string; + error?: string; + error_description?: string; +} + +interface CopilotTokenApiResponse { + token?: string; + expires_at?: number | string; + expires_in?: number | string; + error?: string; + message?: string; +} + export interface DeviceCodeResponse { deviceCode: string; userCode: string; @@ -37,6 +70,43 @@ export interface CopilotAuthState { error?: string; } +export interface CopilotChatResponse { + choices: Array<{ + message: { role: string; content: string }; + finish_reason: string; + }>; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + prompt_tokens_details?: { + cached_tokens: number; + }; + }; + model?: string; + created?: number; + id?: string; +} + +export interface CopilotStreamChunk { + choices: Array<{ + index: number; + delta: { + content?: string | null; + role?: string; + }; + finish_reason?: string | null; + }>; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; + model?: string; + created?: number; + id?: string; +} + /** * GitHubCopilotProvider handles: * - GitHub OAuth device code flow @@ -76,13 +146,21 @@ export class GitHubCopilotProvider { const settings = getSettings(); const hasAccessToken = Boolean(settings.githubCopilotAccessToken); const hasCopilotToken = Boolean(settings.githubCopilotToken); - const tokenExpiresAt = settings.githubCopilotTokenExpiresAt || 0; - const isExpired = tokenExpiresAt > 0 && tokenExpiresAt < Date.now(); + const tokenExpiresAt = settings.githubCopilotTokenExpiresAt; + // Use same expiry logic as getValidCopilotToken: treat missing/invalid expiresAt as expired + const hasKnownExpiry = typeof tokenExpiresAt === "number" && tokenExpiresAt > 0; + const isExpired = !hasKnownExpiry || tokenExpiresAt < Date.now(); - // Authenticated if we have a valid copilot token OR we have access token to refresh - if ((hasCopilotToken && !isExpired) || hasAccessToken) { + // Authenticated if: + // - we have a valid copilot token, OR + // - we have a copilot token (even if expired/unknown expiry) AND we can refresh it via access token. + // Pending if we only have access token but haven't fetched copilot token yet. + if ((hasCopilotToken && !isExpired) || (hasCopilotToken && hasAccessToken)) { return { status: "authenticated" }; } + if (hasAccessToken) { + return { status: "pending" }; + } return { status: "idle" }; } @@ -112,19 +190,47 @@ export class GitHubCopilotProvider { Accept: "application/json", }, body, + throw: false, }); + const data = this.getRequestUrlJson(res) as Partial; + if (res.status !== 200) { - throw new Error(`Failed to get device code: ${res.status}`); + const errorDetail = + typeof data.error_description === "string" + ? data.error_description + : typeof data.error === "string" + ? data.error + : ""; + throw new Error( + errorDetail ? `Failed to get device code: ${errorDetail}` : `Failed to get device code: ${res.status}` + ); + } + + if ( + typeof data.device_code !== "string" || + typeof data.user_code !== "string" || + typeof data.expires_in !== "number" + ) { + throw new Error("Invalid device code response from GitHub"); + } + + const verificationUri = + typeof data.verification_uri === "string" + ? data.verification_uri + : typeof data.verification_uri_complete === "string" + ? data.verification_uri_complete + : null; + if (!verificationUri) { + throw new Error("Invalid device code response from GitHub: missing verification URI"); } - const data = res.json; return { deviceCode: data.device_code, userCode: data.user_code, - verificationUri: data.verification_uri || data.verification_uri_complete, + verificationUri, expiresIn: data.expires_in, - interval: data.interval || 5, + interval: typeof data.interval === "number" && data.interval > 0 ? data.interval : 5, }; } @@ -163,7 +269,7 @@ export class GitHubCopilotProvider { attempt++; onPoll?.(attempt); - await this.delay(interval * 1000); + await this.delay(interval * 1000, controller.signal); // Check again after delay if (controller.signal.aborted || this.authGeneration !== currentGeneration) { @@ -185,6 +291,7 @@ export class GitHubCopilotProvider { Accept: "application/json", }, body, + throw: false, }); // Check abort after HTTP request completes @@ -192,31 +299,26 @@ export class GitHubCopilotProvider { throw new Error("Authentication cancelled by user."); } - // Check HTTP status before parsing JSON + const data = this.getRequestUrlJson(res) as Partial; + + // Check HTTP status before processing body if (res.status !== 200) { - let errorMessage = `HTTP ${res.status}`; - try { - const errorData = res.json; - if (errorData.error_description) { - errorMessage = errorData.error_description; - } else if (errorData.error) { - errorMessage = errorData.error; - } - } catch { - // Cannot parse JSON, use status code - } + const errorMessage = + typeof data.error_description === "string" + ? data.error_description + : typeof data.error === "string" + ? data.error + : `HTTP ${res.status}`; throw new Error(`Token request failed: ${errorMessage}`); } - const data = res.json; - - if (data.access_token) { + if (typeof data.access_token === "string" && data.access_token) { // Final check before storing token if (controller.signal.aborted || this.authGeneration !== currentGeneration) { throw new Error("Authentication cancelled by user."); } // Store access token - updateSetting("githubCopilotAccessToken", data.access_token); + setSettings({ githubCopilotAccessToken: data.access_token }); return data.access_token; } @@ -239,8 +341,12 @@ export class GitHubCopilotProvider { throw new Error("Authorization denied by user."); } - if (data.error) { - throw new Error(data.error_description || data.error); + if (typeof data.error === "string" && data.error) { + throw new Error( + typeof data.error_description === "string" && data.error_description + ? data.error_description + : data.error + ); } } @@ -275,6 +381,7 @@ export class GitHubCopilotProvider { Authorization: `Bearer ${token}`, Accept: "application/json", }, + throw: false, }); // Check if auth was reset during the request (generation changed) @@ -282,11 +389,22 @@ export class GitHubCopilotProvider { throw new Error("Authentication was reset during token refresh."); } + const data = this.getRequestUrlJson(res) as Partial; + if (res.status !== 200) { - throw new Error(`Failed to get Copilot token: ${res.status}`); + const detail = + typeof data.message === "string" + ? data.message + : typeof data.error === "string" + ? data.error + : ""; + throw new Error( + detail + ? `Failed to get Copilot token: ${res.status} (${detail})` + : `Failed to get Copilot token: ${res.status}` + ); } - const data = res.json; const copilotToken = data.token; // Validate token response @@ -294,16 +412,8 @@ export class GitHubCopilotProvider { throw new Error("Invalid response from Copilot API: missing or invalid token"); } - // Handle expires_at: could be seconds, milliseconds, or string timestamp - const rawExpiresAt = Number(data.expires_at); - let expiresAt: number; - if (Number.isFinite(rawExpiresAt) && rawExpiresAt > 0) { - // Determine if it's seconds or milliseconds based on magnitude - expiresAt = rawExpiresAt > 1e12 ? rawExpiresAt : rawExpiresAt * 1000; - } else { - // Invalid or missing expires_at, use default - expiresAt = Date.now() + DEFAULT_TOKEN_EXPIRATION_MS; - } + // Handle expires_at: support numeric seconds/millis, ISO string, and expires_in fallback. + const expiresAt = this.parseCopilotTokenExpiresAt(data); // Final check before storing tokens (generation may have changed) if (this.authGeneration !== currentGeneration) { @@ -311,8 +421,10 @@ export class GitHubCopilotProvider { } // Store copilot token and expiration - updateSetting("githubCopilotToken", copilotToken); - updateSetting("githubCopilotTokenExpiresAt", expiresAt); + setSettings({ + githubCopilotToken: copilotToken, + githubCopilotTokenExpiresAt: expiresAt, + }); return copilotToken; } @@ -324,8 +436,10 @@ export class GitHubCopilotProvider { */ async getValidCopilotToken(): Promise { const settings = getSettings(); - const tokenExpiresAt = settings.githubCopilotTokenExpiresAt || 0; - const isExpired = tokenExpiresAt > 0 && tokenExpiresAt < Date.now() + TOKEN_REFRESH_BUFFER_MS; + const tokenExpiresAt = settings.githubCopilotTokenExpiresAt; + // Treat missing/invalid expiresAt as expired to force refresh + const hasKnownExpiry = typeof tokenExpiresAt === "number" && tokenExpiresAt > 0; + const isExpired = !hasKnownExpiry || tokenExpiresAt < Date.now() + TOKEN_REFRESH_BUFFER_MS; if (settings.githubCopilotToken && !isExpired) { // Reset refresh attempts on successful token use @@ -360,64 +474,220 @@ export class GitHubCopilotProvider { return this.refreshPromise; } + /** + * Build common headers for Copilot API requests + */ + private buildCopilotHeaders(token: string): Record { + return { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "User-Agent": "GitHubCopilotChat/0.22.2024092501", + "Editor-Version": "vscode/1.95.1", + "Copilot-Integration-Id": "vscode-chat", + "Openai-Intent": "conversation-panel", + }; + } + /** * Send chat message to Copilot API */ async sendChatMessage( messages: Array<{ role: string; content: string }>, model: string = "gpt-4o" - ): Promise<{ - choices: Array<{ - message: { role: string; content: string }; - finish_reason: string; - }>; - usage?: { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; + ): Promise { + const doRequest = async (token: string): Promise => { + return await requestUrl({ + url: CHAT_COMPLETIONS_URL, + method: "POST", + headers: this.buildCopilotHeaders(token), + body: JSON.stringify({ + model, + messages, + stream: false, + }), + throw: false, + }); }; - }> { - const token = await this.getValidCopilotToken(); - const res = await requestUrl({ - url: CHAT_COMPLETIONS_URL, - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - "User-Agent": "GitHubCopilotChat/0.22.2024092501", - "Editor-Version": "vscode/1.95.1", - "Copilot-Integration-Id": "vscode-chat", - "Openai-Intent": "conversation-panel", - }, - body: JSON.stringify({ - model, - messages, - stream: false, - }), - }); + let token = await this.getValidCopilotToken(); + let res = await doRequest(token); + + // 401: clear cached token and retry once + if (res.status === 401) { + this.clearCopilotToken(); + token = await this.getValidCopilotToken(); + res = await doRequest(token); + } if (res.status !== 200) { + const errorData = this.getRequestUrlJson(res); let errorDetail = ""; - try { - const errorData = res.json; - errorDetail = - errorData.error?.message || errorData.message || JSON.stringify(errorData); - } catch { - // Cannot parse JSON + if (errorData && typeof errorData === "object") { + const record = errorData as Record; + const nestedError = record.error; + if (nestedError && typeof nestedError === "object") { + const nestedRecord = nestedError as Record; + if (typeof nestedRecord.message === "string") { + errorDetail = nestedRecord.message; + } + } + if (!errorDetail && typeof record.message === "string") { + errorDetail = record.message; + } + if (!errorDetail) { + try { + errorDetail = JSON.stringify(errorData); + } catch { + errorDetail = ""; + } + } + } else if (typeof errorData === "string") { + errorDetail = errorData; } - const statusMessages: Record = { - 401: "Authentication failed - token may be expired", - 403: "Access denied - check your Copilot subscription", - 429: "Rate limited - please wait before retrying", - }; - - const baseMessage = statusMessages[res.status] || `Request failed: ${res.status}`; + const baseMessage = HTTP_STATUS_MESSAGES[res.status] || `Request failed: ${res.status}`; throw new Error(errorDetail ? `${baseMessage}: ${errorDetail}` : baseMessage); } - return res.json; + const data = this.getRequestUrlJson(res); + + // Validate response structure + if (!data || typeof data !== "object" || !Array.isArray((data as Record).choices)) { + throw new Error("Invalid response from Copilot API: missing choices array"); + } + + return data as CopilotChatResponse; + } + + /** + * Send chat message to Copilot API with streaming response. + * Uses fetch API for true streaming support. + * @param messages - Chat messages + * @param model - Model name + * @param signal - Optional AbortSignal for cancellation + */ + async *sendChatMessageStream( + messages: Array<{ role: string; content: string }>, + model: string = "gpt-4o", + signal?: AbortSignal + ): AsyncGenerator { + const doRequest = async (token: string): Promise => { + return await fetch(CHAT_COMPLETIONS_URL, { + method: "POST", + headers: { + ...this.buildCopilotHeaders(token), + Accept: "text/event-stream", + }, + body: JSON.stringify({ + model, + messages, + stream: true, + }), + signal, + }); + }; + + let token = await this.getValidCopilotToken(); + let response = await doRequest(token); + + // 401: clear cached token and retry once + if (response.status === 401) { + try { + await response.body?.cancel(); + } catch { + // Ignore cancellation errors - body may already be closed + } + this.clearCopilotToken(); + token = await this.getValidCopilotToken(); + response = await doRequest(token); + } + + if (!response.ok) { + const errorText = await response.text(); + const baseMessage = HTTP_STATUS_MESSAGES[response.status] || `Request failed: ${response.status}`; + throw new Error(errorText ? `${baseMessage}: ${errorText}` : baseMessage); + } + + // Verify response is SSE format + const contentType = response.headers.get("content-type") || ""; + if (!contentType.includes("text/event-stream")) { + throw new Error(`Expected text/event-stream but received ${contentType || "unknown"}`); + } + + if (!response.body) { + throw new Error("Response body is not available for streaming"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + // Declare chunkQueue before parser to avoid use-before-define issues + const chunkQueue: CopilotStreamChunk[] = []; + let receivedDone = false; + + // Use eventsource-parser for robust SSE parsing + const parser = createParser((event: ParsedEvent | ReconnectInterval) => { + if (event.type !== "event") { + return; + } + + const data = event.data; + if (data === "[DONE]") { + receivedDone = true; + return; + } + + try { + const chunk = JSON.parse(data) as CopilotStreamChunk; + // Store chunk in a queue to be yielded + chunkQueue.push(chunk); + } catch { + // Skip invalid JSON + } + }); + + try { + while (true) { + // Exit early if we received [DONE] + if (receivedDone) break; + + const { done, value } = await reader.read(); + if (done) break; + + // Feed data to parser + const text = decoder.decode(value, { stream: true }); + parser.feed(text); + + // Yield all queued chunks + while (chunkQueue.length > 0) { + const chunk = chunkQueue.shift(); + if (chunk) { + yield chunk; + } + } + } + + // Flush decoder at the end + const finalText = decoder.decode(); + if (finalText) { + parser.feed(finalText); + while (chunkQueue.length > 0) { + const chunk = chunkQueue.shift(); + if (chunk) { + yield chunk; + } + } + } + } finally { + // Cancel the reader to abort any pending reads, then release the lock + // Wrap in try-catch to avoid overwriting the original error (e.g., AbortError) + try { + await reader.cancel(); + } catch { + // Ignore cancellation errors - stream may already be closed + } + reader.releaseLock(); + } } /** @@ -439,35 +709,161 @@ export class GitHubCopilotProvider { this.abortPolling(); // This will abort any ongoing polling operations this.refreshPromise = null; this.refreshAttempts = 0; - updateSetting("githubCopilotAccessToken", ""); - updateSetting("githubCopilotToken", ""); - updateSetting("githubCopilotTokenExpiresAt", 0); + setSettings({ + githubCopilotAccessToken: "", + githubCopilotToken: "", + githubCopilotTokenExpiresAt: 0, + }); } /** * List available models from GitHub Copilot API */ async listModels(): Promise { - const token = await this.getValidCopilotToken(); + const doRequest = async (token: string): Promise => { + return await requestUrl({ + url: MODELS_URL, + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "Copilot-Integration-Id": "vscode-chat", + }, + throw: false, + }); + }; - const res = await requestUrl({ - url: MODELS_URL, - method: "GET", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "Copilot-Integration-Id": "vscode-chat", - }, - }); + let token = await this.getValidCopilotToken(); + let res = await doRequest(token); + + // 401: clear cached token and retry once + if (res.status === 401) { + this.clearCopilotToken(); + token = await this.getValidCopilotToken(); + res = await doRequest(token); + } if (res.status !== 200) { throw new Error(`Failed to list models: ${res.status}`); } - return res.json as GitHubCopilotModelResponse; + return this.getRequestUrlJson(res) as GitHubCopilotModelResponse; } - private delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + /** + * Clear stored Copilot token so the next request forces a refresh. + */ + private clearCopilotToken(): void { + setSettings({ + githubCopilotToken: "", + githubCopilotTokenExpiresAt: 0, + }); + } + + /** + * Best-effort JSON extraction from requestUrl responses, which may return an object or a JSON string. + * @param response - Obsidian requestUrl response. + * @returns Parsed JSON value, or the original `response.json` value if parsing is not possible. + */ + private getRequestUrlJson(response: RequestUrlResponse): unknown { + if (typeof response.json === "string") { + try { + return JSON.parse(response.json); + } catch { + return response.json; + } + } + return response.json; + } + + /** + * Parse the Copilot token expiry timestamp from the token endpoint response. + * Supports numeric seconds/milliseconds `expires_at`, ISO string `expires_at`, and `expires_in` seconds. + * @param data - Parsed JSON response from Copilot token endpoint. + */ + private parseCopilotTokenExpiresAt(data: unknown): number { + if (!data || typeof data !== "object") { + return Date.now() + DEFAULT_TOKEN_EXPIRATION_MS; + } + + const record = data as Record; + + const expiresAt = this.parseExpiresAtValue(record.expires_at); + if (expiresAt !== null) { + return expiresAt; + } + + const expiresInRaw = record.expires_in; + const expiresInSeconds = + typeof expiresInRaw === "number" + ? expiresInRaw + : typeof expiresInRaw === "string" + ? Number(expiresInRaw) + : Number.NaN; + if (Number.isFinite(expiresInSeconds) && expiresInSeconds > 0) { + return Date.now() + expiresInSeconds * 1000; + } + + return Date.now() + DEFAULT_TOKEN_EXPIRATION_MS; + } + + /** + * Parse `expires_at` value which may be seconds, milliseconds, or an ISO string. + * @param expiresAt - Raw expires_at value. + * @returns Milliseconds since epoch, or null if parsing fails. + */ + private parseExpiresAtValue(expiresAt: unknown): number | null { + if (typeof expiresAt === "number") { + if (!Number.isFinite(expiresAt) || expiresAt <= 0) { + return null; + } + return expiresAt > 1e12 ? expiresAt : expiresAt * 1000; + } + + if (typeof expiresAt === "string") { + const trimmed = expiresAt.trim(); + if (!trimmed) return null; + + const numeric = Number(trimmed); + if (Number.isFinite(numeric) && numeric > 0) { + return numeric > 1e12 ? numeric : numeric * 1000; + } + + const parsed = Date.parse(trimmed); + if (!Number.isNaN(parsed) && parsed > 0) { + return parsed; + } + } + + return null; + } + + /** + * Delay that can be cancelled via AbortSignal. + * @param ms - Duration in milliseconds. + * @param signal - Optional cancellation signal. + */ + private delay(ms: number, signal?: AbortSignal): Promise { + if (!signal) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + if (signal.aborted) { + return Promise.reject(new Error("Authentication cancelled by user.")); + } + + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timeoutId); + reject(new Error("Authentication cancelled by user.")); + }; + + const timeoutId = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + + signal.addEventListener("abort", onAbort, { once: true }); + }); } } diff --git a/src/settings/v2/components/GitHubCopilotAuth.tsx b/src/settings/v2/components/GitHubCopilotAuth.tsx index 1177e01e..fa84f63e 100644 --- a/src/settings/v2/components/GitHubCopilotAuth.tsx +++ b/src/settings/v2/components/GitHubCopilotAuth.tsx @@ -10,7 +10,7 @@ import { useSettingsValue } from "@/settings/model"; import { ModelImporter } from "@/settings/v2/components/ModelImporter"; import { ChevronDown, ChevronUp, Loader2, Copy } from "lucide-react"; import { Notice } from "obsidian"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; type AuthStep = "idle" | "pending" | "user" | "polling" | "done" | "error"; @@ -25,6 +25,19 @@ export function GitHubCopilotAuth() { const [deviceCode, setDeviceCode] = useState(null); const [error, setError] = useState(null); const [expanded, setExpanded] = useState(false); + const authRequestIdRef = useRef(0); + const isMountedRef = useRef(true); + + // Cleanup on unmount: abort polling and prevent setState + useEffect(() => { + isMountedRef.current = true; + + return () => { + isMountedRef.current = false; + authRequestIdRef.current += 1; + copilotProvider.abortPolling(); + }; + }, [copilotProvider]); // Check initial auth state useEffect(() => { @@ -34,28 +47,54 @@ export function GitHubCopilotAuth() { } }, [copilotProvider]); - // Update auth step when settings change + // Update auth step when settings change - reuse getAuthState() for consistency useEffect(() => { - if (settings.githubCopilotToken || settings.githubCopilotAccessToken) { - setAuthStep("done"); + const state = copilotProvider.getAuthState(); + if (state.status === "authenticated") { + // Don't override in-flight auth UI; handleCompleteAuth() will set the final state. + if (authStep !== "pending" && authStep !== "user" && authStep !== "polling") { + setAuthStep("done"); + } + } else if (authStep === "done") { + // Token expired or cleared, reset to idle + setAuthStep("idle"); } - }, [settings.githubCopilotToken, settings.githubCopilotAccessToken]); + }, [ + settings.githubCopilotToken, + settings.githubCopilotAccessToken, + settings.githubCopilotTokenExpiresAt, + copilotProvider, + authStep, + ]); /** * Initiates the GitHub OAuth device code flow. * Requests a device code and displays it to the user for authorization. */ const handleStartAuth = async () => { + const requestId = ++authRequestIdRef.current; + setAuthStep("pending"); setError(null); try { const deviceCodeResponse = await copilotProvider.startDeviceCodeFlow(); + + // Check if request was cancelled or component unmounted + if (!isMountedRef.current || requestId !== authRequestIdRef.current) { + return; + } + setDeviceCode(deviceCodeResponse); setAuthStep("user"); setExpanded(true); new Notice("Please authorize in your browser, then click 'Complete'"); } catch (e: unknown) { + // Ignore errors if request was cancelled or component unmounted + if (!isMountedRef.current || requestId !== authRequestIdRef.current) { + return; + } + const errorMessage = e instanceof Error ? e.message : String(e); setError(errorMessage); setAuthStep("error"); @@ -73,6 +112,8 @@ export function GitHubCopilotAuth() { return; } + const requestId = ++authRequestIdRef.current; + setAuthStep("polling"); setError(null); @@ -83,10 +124,21 @@ export function GitHubCopilotAuth() { deviceCode.expiresIn ); await copilotProvider.fetchCopilotToken(); + + // Check if request was cancelled or component unmounted + if (!isMountedRef.current || requestId !== authRequestIdRef.current) { + return; + } + setAuthStep("done"); setDeviceCode(null); new Notice("GitHub Copilot connected successfully!"); } catch (e: unknown) { + // Ignore errors if request was cancelled or component unmounted + if (!isMountedRef.current || requestId !== authRequestIdRef.current) { + return; + } + const errorMessage = e instanceof Error ? e.message : String(e); setError(errorMessage); setAuthStep("error"); @@ -99,6 +151,8 @@ export function GitHubCopilotAuth() { * Disconnects the user from GitHub Copilot. */ const handleReset = () => { + // Increment requestId to invalidate any in-flight async operations + authRequestIdRef.current += 1; copilotProvider.resetAuth(); setAuthStep("idle"); setDeviceCode(null);