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
This commit is contained in:
Emt-lin 2026-01-16 03:31:48 +08:00 committed by GitHub
parent 957b62a9f3
commit 5bfb6fb6f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 698 additions and 118 deletions

View file

@ -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<ChatResult> {
// 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<ChatGenerationChunk> {
// 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<string, unknown> = {};
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
*/

View file

@ -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<number, string> = {
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<GitHubDeviceCodeApiResponse>;
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<GitHubOAuthTokenResponse>;
// 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<CopilotTokenApiResponse>;
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<string> {
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<string, string> {
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<CopilotChatResponse> {
const doRequest = async (token: string): Promise<RequestUrlResponse> => {
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<string, unknown>;
const nestedError = record.error;
if (nestedError && typeof nestedError === "object") {
const nestedRecord = nestedError as Record<string, unknown>;
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<number, string> = {
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<string, unknown>).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<CopilotStreamChunk> {
const doRequest = async (token: string): Promise<Response> => {
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<GitHubCopilotModelResponse> {
const token = await this.getValidCopilotToken();
const doRequest = async (token: string): Promise<RequestUrlResponse> => {
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<void> {
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<string, unknown>;
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<void> {
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 });
});
}
}

View file

@ -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<DeviceCodeResponse | null>(null);
const [error, setError] = useState<string | null>(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);