mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
957b62a9f3
commit
5bfb6fb6f5
3 changed files with 698 additions and 118 deletions
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue