mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
feat: refactor GitHubCopilotChatModel to support tool calling (#2242)
* feat: refactor GitHubCopilotChatModel to support tool calling Refactored GitHubCopilotChatModel from BaseChatModel to ChatOpenAICompletions to enable bindTools() for Agent Mode tool calling support. Key changes: - Extend ChatOpenAICompletions instead of BaseChatModel for native tool support - Override _streamResponseChunks to fix Copilot-specific streaming issues: 1. Missing delta.role when proxying Claude models (defaults to "assistant") 2. Non-string delta.content arrays from Claude (normalized to string) - Use public convertCompletionsDeltaToBaseMessageChunk API (not deprecated method) - Inject Copilot auth via configuration.fetch wrapper with 401/403 retry - Export COPILOT_API_BASE and add public token management wrappers * fix: improve type safety and retry logic in GitHubCopilotChatModel - Replace loose `[key: string]: any` params interface with type derived from ChatOpenAICompletions constructor fields for proper type checking - Limit auth retry to 401 only (not 403) to match GitHubCopilotProvider behavior and avoid useless token refresh on permanent permission errors - Replace 80-line _streamResponseChunks fork with minimal _convertCompletionsDeltaToBaseMessageChunk override - Remove unused imports (BaseChatModelParams, AIMessageChunk, etc.) * refactor: remove dead code from GitHubCopilotProvider after ChatOpenAICompletions migration Chat completions are now handled by GitHubCopilotChatModel (extends ChatOpenAICompletions) with auth injected via configuration.fetch. The old Provider-level request methods and their supporting types are no longer referenced anywhere. Removed: - sendChatMessage, sendChatMessageStream, executeWithTokenRetry methods - CopilotChatResponse, CopilotStreamChunk, CopilotRequestOptions interfaces - CHAT_COMPLETIONS_URL, HTTP_STATUS_MESSAGES constants - Unused imports: createParser, ParsedEvent, ReconnectInterval, FetchImplementation * feat: add model policy terms UI, update Copilot headers, and fix review issues - Update Copilot request headers to latest VSCode versions - Cache model policy terms and surface activation guidance on 400 errors - Extend GitHubCopilotModel interface with billing/policy/endpoint fields - Show verification error with parsed Markdown links in ModelImporter - Fix unused logInfo import, guard Request instanceof check, case-insensitive "not supported" match, clear policy cache on resetAuth
This commit is contained in:
parent
f51036fa8a
commit
11f3878244
6 changed files with 311 additions and 541 deletions
|
|
@ -370,6 +370,8 @@ export default class ChatModelManager {
|
|||
// This doesn't throw on HTTP errors so 401 retry logic works correctly.
|
||||
// WARNING: AbortSignal/timeout will NOT work when enableCors is true
|
||||
// because Obsidian's requestUrl doesn't support cancellation.
|
||||
// Reason: fetchImplementation is passed to the authed fetch wrapper inside
|
||||
// GitHubCopilotChatModel, which injects Copilot token and headers per request.
|
||||
fetchImplementation: customModel.enableCors ? safeFetchNoThrow : undefined,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,245 +1,211 @@
|
|||
import {
|
||||
BaseChatModel,
|
||||
type BaseChatModelParams,
|
||||
} from "@langchain/core/language_models/chat_models";
|
||||
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";
|
||||
import type { BaseMessageChunk, MessageContent } from "@langchain/core/messages";
|
||||
import { ChatOpenAICompletions } from "@langchain/openai";
|
||||
import { COPILOT_API_BASE, GitHubCopilotProvider } from "./GitHubCopilotProvider";
|
||||
import type { FetchImplementation } from "@/utils";
|
||||
import { extractTextFromChunk } from "@/utils";
|
||||
|
||||
// Approximate characters per token for English text
|
||||
const CHARS_PER_TOKEN = 4;
|
||||
|
||||
export interface GitHubCopilotChatModelParams extends BaseChatModelParams {
|
||||
modelName: string;
|
||||
streaming?: boolean;
|
||||
/** Custom fetch implementation for CORS bypass (e.g., safeFetch on mobile platforms) */
|
||||
fetchImplementation?: FetchImplementation;
|
||||
/**
|
||||
* Normalize delta.content to a string.
|
||||
*
|
||||
* Reason: GitHub Copilot proxies multiple model families (Claude, GPT, etc.).
|
||||
* Claude models may return streaming delta.content as an array of content parts
|
||||
* (e.g., `[{type: "text", text: "..."}]`) instead of a plain string.
|
||||
* ChatOpenAICompletions' `_streamResponseChunks` skips chunks with non-string
|
||||
* content, causing all text to be silently dropped. This normalizer ensures
|
||||
* content is always a string before it reaches that check.
|
||||
*/
|
||||
function normalizeDeltaContent(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (content == null) return "";
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part) => {
|
||||
if (typeof part === "string") return part;
|
||||
if (part && typeof part === "object" && typeof part.text === "string") {
|
||||
return part.text;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
if (typeof content === "object" && typeof (content as any).text === "string") {
|
||||
return (content as any).text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Extract the constructor fields type from ChatOpenAICompletions. */
|
||||
type ChatOpenAICompletionsFields = NonNullable<ConstructorParameters<typeof ChatOpenAICompletions>[0]>;
|
||||
|
||||
/**
|
||||
* LangChain BaseChatModel implementation for GitHub Copilot
|
||||
* Constructor params for GitHubCopilotChatModel.
|
||||
* Inherits all ChatOpenAICompletions fields (temperature, maxTokens, etc.)
|
||||
* and adds Copilot-specific options.
|
||||
*/
|
||||
export class GitHubCopilotChatModel extends BaseChatModel {
|
||||
export type GitHubCopilotChatModelParams = ChatOpenAICompletionsFields & {
|
||||
/** Custom fetch implementation for CORS bypass (e.g., safeFetchNoThrow on mobile) */
|
||||
fetchImplementation?: FetchImplementation;
|
||||
};
|
||||
|
||||
/**
|
||||
* GitHub Copilot ChatModel built on top of ChatOpenAICompletions.
|
||||
*
|
||||
* Reason: We extend ChatOpenAICompletions instead of ChatOpenAI because:
|
||||
* 1. ChatOpenAI routes between Completions API and Responses API internally.
|
||||
* GitHub Copilot only supports the Chat Completions API endpoint.
|
||||
* 2. ChatOpenAICompletions provides `bindTools()` (via BaseChatOpenAI),
|
||||
* `_streamResponseChunks`, and `_convertCompletionsDeltaToBaseMessageChunk`
|
||||
* directly — no routing indirection.
|
||||
* 3. `_convertCompletionsDeltaToBaseMessageChunk` is directly overridable,
|
||||
* allowing us to normalize non-string content from Claude models.
|
||||
*
|
||||
* Authentication (dynamic Copilot token refresh) and Copilot-specific headers
|
||||
* are injected via `configuration.fetch` using GitHubCopilotProvider's lifecycle.
|
||||
*/
|
||||
export class GitHubCopilotChatModel extends ChatOpenAICompletions {
|
||||
lc_serializable = false;
|
||||
lc_namespace = ["langchain", "chat_models", "github_copilot"];
|
||||
|
||||
private provider: GitHubCopilotProvider;
|
||||
modelName: string;
|
||||
streaming: boolean;
|
||||
/** Fetch implementation to use - custom implementation for CORS bypass, native fetch otherwise */
|
||||
private fetchImpl: FetchImplementation;
|
||||
/**
|
||||
* Build a fetch wrapper that injects a valid Copilot token and custom headers
|
||||
* on every request, with automatic 401 retry after token refresh.
|
||||
*
|
||||
* @param provider - GitHubCopilotProvider singleton for token management
|
||||
* @param baseFetch - Underlying fetch implementation (native or CORS-safe)
|
||||
* @returns A fetch-compatible function with Copilot auth injected
|
||||
*/
|
||||
private static buildAuthedFetch(
|
||||
provider: GitHubCopilotProvider,
|
||||
baseFetch: FetchImplementation
|
||||
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
|
||||
return async (input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> => {
|
||||
// Reason: OpenAI SDK v6 always calls fetch(urlString, init), so we only need
|
||||
// to handle string and URL inputs. Request objects are not used by the SDK,
|
||||
// but we extract the URL defensively to avoid silent failures.
|
||||
// Note: If a future SDK version passes Request objects, this wrapper would
|
||||
// need to clone the Request to preserve method/body/headers and support retry.
|
||||
// Reason: Guard `typeof Request` to avoid ReferenceError in environments
|
||||
// where the Request global may not exist (e.g., some Obsidian mobile runtimes).
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: typeof Request !== "undefined" && input instanceof Request
|
||||
? input.url
|
||||
: input.toString();
|
||||
|
||||
constructor(fields: GitHubCopilotChatModelParams) {
|
||||
super(fields);
|
||||
this.provider = GitHubCopilotProvider.getInstance();
|
||||
this.modelName = fields.modelName;
|
||||
this.streaming = fields.streaming ?? true;
|
||||
// Use provided fetch implementation or default to native fetch
|
||||
this.fetchImpl = fields.fetchImplementation ?? fetch;
|
||||
const doRequest = async (token: string): Promise<Response> => {
|
||||
const copilotHeaders = provider.buildCopilotRequestHeaders(token);
|
||||
const mergedHeaders = new Headers(init.headers);
|
||||
// Copilot headers take precedence (especially Authorization)
|
||||
for (const [key, value] of Object.entries(copilotHeaders)) {
|
||||
mergedHeaders.set(key, value);
|
||||
}
|
||||
|
||||
return baseFetch(url, { ...init, headers: mergedHeaders });
|
||||
};
|
||||
|
||||
let token = await provider.getValidCopilotToken();
|
||||
let response = await doRequest(token);
|
||||
|
||||
// 401: invalidate cached token and retry once with a fresh one.
|
||||
// Reason: Only retry on 401 (Unauthorized / expired token). 403 means
|
||||
// "Forbidden" (e.g., no Copilot subscription) — a permanent condition
|
||||
// where token refresh won't help. This matches GitHubCopilotProvider's
|
||||
// own retry logic which also limits retries to 401.
|
||||
if (response.status === 401) {
|
||||
try {
|
||||
await response.body?.cancel();
|
||||
} catch {
|
||||
// Ignore cancellation errors — body may already be closed
|
||||
}
|
||||
provider.invalidateCopilotToken();
|
||||
token = await provider.getValidCopilotToken();
|
||||
response = await doRequest(token);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
_llmType(): string {
|
||||
/**
|
||||
* Create a Copilot-backed ChatOpenAICompletions instance.
|
||||
* Wires up dynamic token refresh and Copilot headers via a custom fetch wrapper.
|
||||
*/
|
||||
constructor(fields: GitHubCopilotChatModelParams) {
|
||||
const { fetchImplementation, configuration, apiKey, ...rest } = fields;
|
||||
|
||||
const provider = GitHubCopilotProvider.getInstance();
|
||||
const baseFetch = fetchImplementation ?? (configuration?.fetch as FetchImplementation) ?? fetch;
|
||||
const authedFetch = GitHubCopilotChatModel.buildAuthedFetch(provider, baseFetch);
|
||||
|
||||
super({
|
||||
...rest,
|
||||
// ChatOpenAICompletions requires an apiKey but Copilot tokens are dynamic;
|
||||
// real Authorization is injected in the fetch wrapper above.
|
||||
apiKey: apiKey || "copilot-dynamic-token",
|
||||
// Reason: Copilot API may not support stream_options.include_usage,
|
||||
// which ChatOpenAI sends by default. Disable to avoid potential 400 errors.
|
||||
streamUsage: false,
|
||||
configuration: {
|
||||
...(configuration ?? {}),
|
||||
// Reason: OpenAI SDK appends "/chat/completions" to baseURL automatically
|
||||
baseURL: (configuration?.baseURL as string) ?? COPILOT_API_BASE,
|
||||
fetch: authedFetch,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** LangChain model type identifier. */
|
||||
override _llmType(): string {
|
||||
return "github-copilot";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert LangChain message type to OpenAI role.
|
||||
* Note: Copilot API may not support tool/function roles, so we normalize them to user.
|
||||
* Override delta-to-chunk conversion to fix two Copilot-specific issues:
|
||||
*
|
||||
* 1. Missing role: Copilot API (especially when proxying Claude models) may omit
|
||||
* `delta.role` from streaming chunks. The parent converter only creates
|
||||
* AIMessageChunk (with tool_call_chunks) for role="assistant". Without it,
|
||||
* chunks fall through to ChatMessageChunk which lacks tool_call_chunks,
|
||||
* breaking Agent mode tool calling entirely.
|
||||
*
|
||||
* 2. Non-string content: Claude models may return delta.content as an array of
|
||||
* content parts. The parent's _streamResponseChunks skips chunks where
|
||||
* `typeof content !== "string"`, silently dropping all text.
|
||||
*/
|
||||
private convertMessageType(messageType: string): string {
|
||||
switch (messageType) {
|
||||
case "human":
|
||||
return "user";
|
||||
case "ai":
|
||||
return "assistant";
|
||||
case "system":
|
||||
return "system";
|
||||
case "tool":
|
||||
case "function":
|
||||
// Copilot API may not support these roles, normalize to user
|
||||
return "user";
|
||||
case "generic":
|
||||
default:
|
||||
return "user";
|
||||
protected override _convertCompletionsDeltaToBaseMessageChunk(
|
||||
delta: Record<string, any>,
|
||||
rawResponse: any,
|
||||
// Reason: Parent expects OpenAI's ChatCompletionRole type, but we accept any string
|
||||
// to avoid coupling to the exact OpenAI SDK type. Cast is safe because we pass through.
|
||||
defaultRole?: any
|
||||
): BaseMessageChunk {
|
||||
// Reason: Copilot API omits delta.role when proxying Claude models.
|
||||
// The parent converter uses `delta.role ?? defaultRole` to determine message type.
|
||||
// If both are undefined, it falls through to ChatMessageChunk (no tool_call_chunks).
|
||||
// Model responses are always from the assistant role, so defaulting here is safe.
|
||||
// We set defaultRole instead of mutating delta.role to avoid modifying transport objects.
|
||||
if (!delta.role && !defaultRole) {
|
||||
defaultRole = "assistant";
|
||||
}
|
||||
|
||||
// Reason: Mutate delta.content in place instead of spreading.
|
||||
// OpenAI SDK's delta objects may have non-enumerable properties (e.g., tool_calls)
|
||||
// that would be lost by `{ ...delta }` spread. Direct mutation is safe because
|
||||
// each delta is a single-use streaming chunk.
|
||||
delta.content = normalizeDeltaContent(delta.content);
|
||||
|
||||
return super._convertCompletionsDeltaToBaseMessageChunk(delta, rawResponse, defaultRole);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert LangChain messages to Copilot API format.
|
||||
* Simple token estimation based on character count.
|
||||
* Kept as a safe fallback for direct usage outside ChatModelManager.
|
||||
*/
|
||||
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
|
||||
*/
|
||||
async _generate(
|
||||
messages: BaseMessage[],
|
||||
options: this["ParsedCallOptions"],
|
||||
_runManager?: CallbackManagerForLLMRun
|
||||
): Promise<ChatResult> {
|
||||
const chatMessages = this.toCopilotMessages(messages);
|
||||
|
||||
// Call Copilot API with fetchImpl for CORS detection parity with other providers
|
||||
const response = await this.provider.sendChatMessage(chatMessages, {
|
||||
model: this.modelName,
|
||||
fetchImpl: this.fetchImpl,
|
||||
signal: options?.signal,
|
||||
});
|
||||
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,
|
||||
response_metadata: responseMetadata,
|
||||
}),
|
||||
generationInfo: { finish_reason: finishReason },
|
||||
};
|
||||
|
||||
return {
|
||||
generations: [generation],
|
||||
llmOutput: {
|
||||
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 yieldedUsableChunk = false;
|
||||
|
||||
// Stream directly, no fallback - errors are propagated to caller
|
||||
for await (const chunk of this.provider.sendChatMessageStream(chatMessages, {
|
||||
model: this.modelName,
|
||||
signal: options?.signal,
|
||||
fetchImpl: this.fetchImpl,
|
||||
})) {
|
||||
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);
|
||||
}
|
||||
|
||||
yieldedUsableChunk = true;
|
||||
yield generationChunk;
|
||||
}
|
||||
|
||||
// Detect silent failures where Provider yielded chunks but none were usable by ChatModel.
|
||||
// Provider's check catches "no JSON at all", this catches "JSON but no usable content".
|
||||
if (!yieldedUsableChunk) {
|
||||
throw new Error(
|
||||
"GitHub Copilot streaming produced no usable chunks (no content, finish_reason, or usage)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple token estimation based on character count
|
||||
*/
|
||||
async getNumTokens(content: MessageContent): Promise<number> {
|
||||
override async getNumTokens(content: MessageContent): Promise<number> {
|
||||
const text = extractTextFromChunk(content);
|
||||
if (!text) return 0;
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { getSettings, setSettings } from "@/settings/model";
|
||||
import { getDecryptedKey } from "@/encryptionService";
|
||||
import { GitHubCopilotModelResponse } from "@/settings/providerModels";
|
||||
import type { FetchImplementation } from "@/utils";
|
||||
import { requestUrl, type RequestUrlResponse } from "obsidian";
|
||||
import { createParser, type ParsedEvent, type ReconnectInterval } from "eventsource-parser";
|
||||
import { AuthCancelledError } from "./errors";
|
||||
|
||||
/**
|
||||
|
|
@ -16,8 +14,7 @@ const CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
|||
const DEVICE_CODE_URL = "https://github.com/login/device/code";
|
||||
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
||||
const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
|
||||
const COPILOT_API_BASE = "https://api.githubcopilot.com";
|
||||
const CHAT_COMPLETIONS_URL = `${COPILOT_API_BASE}/chat/completions`;
|
||||
export const COPILOT_API_BASE = "https://api.githubcopilot.com";
|
||||
const MODELS_URL = `${COPILOT_API_BASE}/models`;
|
||||
|
||||
// Token refresh buffer: refresh 1 minute before expiration
|
||||
|
|
@ -27,13 +24,6 @@ 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;
|
||||
|
|
@ -72,61 +62,14 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for Copilot API requests.
|
||||
* Using an options object instead of positional parameters for consistency and extensibility.
|
||||
*/
|
||||
export interface CopilotRequestOptions {
|
||||
/** Model name (default: "gpt-4o") */
|
||||
model?: string;
|
||||
/** AbortSignal for cancellation/timeout. NOTE: Not honored when using safeFetch (CORS bypass). */
|
||||
signal?: AbortSignal;
|
||||
/** Custom fetch implementation (e.g., safeFetch for CORS bypass on mobile platforms) */
|
||||
fetchImpl?: FetchImplementation;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHubCopilotProvider handles:
|
||||
* - GitHub OAuth device code flow
|
||||
* - Token management (access token + copilot token)
|
||||
* - Chat API calls
|
||||
* - Model listing
|
||||
*
|
||||
* Chat completions are handled by GitHubCopilotChatModel (extends ChatOpenAICompletions),
|
||||
* which uses this provider for token lifecycle via buildCopilotRequestHeaders/getValidCopilotToken.
|
||||
*
|
||||
* WARNING: This uses GitHub Copilot's internal API which is not officially
|
||||
* supported for third-party applications. Use at your own risk.
|
||||
|
|
@ -137,6 +80,12 @@ export class GitHubCopilotProvider {
|
|||
private abortController: AbortController | null = null;
|
||||
private refreshPromise: Promise<string> | null = null;
|
||||
private refreshAttempts = 0;
|
||||
/**
|
||||
* Cache of model policy terms keyed by model ID.
|
||||
* Populated after each listModels() call. Used to surface helpful
|
||||
* "enable this model" guidance when a 400 "not supported" error occurs.
|
||||
*/
|
||||
private modelPolicyTermsCache = new Map<string, string>();
|
||||
/**
|
||||
* Auth generation counter - incremented on reset to invalidate in-flight operations.
|
||||
* This prevents race conditions where an async operation completes after resetAuth()
|
||||
|
|
@ -498,268 +447,15 @@ export class GitHubCopilotProvider {
|
|||
return {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"User-Agent": "GitHubCopilotChat/0.22.2024092501",
|
||||
"Editor-Version": "vscode/1.95.1",
|
||||
"User-Agent": "GitHubCopilotChat/0.38.2026022001",
|
||||
"Editor-Version": "vscode/1.110.0",
|
||||
"Editor-Plugin-Version": "copilot-chat/0.38.2026022001",
|
||||
"Copilot-Integration-Id": "vscode-chat",
|
||||
"Openai-Intent": "conversation-panel",
|
||||
"X-GitHub-Api-Version": "2025-05-01",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a fetch request with automatic 401 retry.
|
||||
* On 401, clears cached Copilot token and retries once with a fresh token.
|
||||
* @param doRequest - Function that performs the actual request given a token
|
||||
* @returns The response from the successful request
|
||||
*/
|
||||
private async executeWithTokenRetry(
|
||||
doRequest: (token: string) => Promise<Response>
|
||||
): Promise<Response> {
|
||||
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);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send chat message to Copilot API.
|
||||
* Uses fetch API for parity with other providers and to surface CORS issues during ping validation.
|
||||
* @param messages - Chat messages
|
||||
* @param options - Request options (model, signal, fetchImpl)
|
||||
*/
|
||||
async sendChatMessage(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
options: CopilotRequestOptions = {}
|
||||
): Promise<CopilotChatResponse> {
|
||||
const { model = "gpt-4o", signal, fetchImpl } = options;
|
||||
// Use provided fetch implementation or default to native fetch
|
||||
const fetchImplementation = fetchImpl ?? fetch;
|
||||
|
||||
const response = await this.executeWithTokenRetry((token) =>
|
||||
fetchImplementation(CHAT_COMPLETIONS_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...this.buildCopilotHeaders(token),
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
stream: false,
|
||||
}),
|
||||
signal,
|
||||
})
|
||||
);
|
||||
|
||||
const responseText = await response.text();
|
||||
let data: unknown = null;
|
||||
if (responseText) {
|
||||
try {
|
||||
data = JSON.parse(responseText);
|
||||
} catch {
|
||||
data = responseText;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorDetail = "";
|
||||
if (data && typeof data === "object") {
|
||||
const record = data 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(data);
|
||||
} catch {
|
||||
errorDetail = "";
|
||||
}
|
||||
}
|
||||
} else if (typeof data === "string") {
|
||||
errorDetail = data;
|
||||
}
|
||||
|
||||
const baseMessage =
|
||||
HTTP_STATUS_MESSAGES[response.status] || `Request failed: ${response.status}`;
|
||||
throw new Error(errorDetail ? `${baseMessage}: ${errorDetail}` : baseMessage);
|
||||
}
|
||||
|
||||
// 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 options - Request options (model, signal, fetchImpl)
|
||||
*/
|
||||
async *sendChatMessageStream(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
options: CopilotRequestOptions = {}
|
||||
): AsyncGenerator<CopilotStreamChunk> {
|
||||
const { model = "gpt-4o", signal, fetchImpl } = options;
|
||||
// Use provided fetch implementation or default to native fetch
|
||||
const fetchImplementation = fetchImpl ?? fetch;
|
||||
|
||||
const response = await this.executeWithTokenRetry((token) =>
|
||||
fetchImplementation(CHAT_COMPLETIONS_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...this.buildCopilotHeaders(token),
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
stream: true,
|
||||
}),
|
||||
signal,
|
||||
})
|
||||
);
|
||||
|
||||
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
|
||||
// Reason: safeFetch (based on requestUrl) may not return accurate content-type headers,
|
||||
// so we relax the validation to also accept empty content-type or application/json
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (
|
||||
contentType &&
|
||||
!contentType.includes("text/event-stream") &&
|
||||
!contentType.includes("application/json")
|
||||
) {
|
||||
throw new Error(`Expected text/event-stream but received ${contentType}`);
|
||||
}
|
||||
|
||||
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;
|
||||
let yieldedChunks = 0;
|
||||
let rawResponsePreview = ""; // For diagnostics if no chunks are produced
|
||||
|
||||
// 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 });
|
||||
// Capture first 500 chars for diagnostics if streaming fails
|
||||
if (rawResponsePreview.length < 500) {
|
||||
rawResponsePreview += text.slice(0, 500 - rawResponsePreview.length);
|
||||
}
|
||||
parser.feed(text);
|
||||
|
||||
// Yield all queued chunks
|
||||
while (chunkQueue.length > 0) {
|
||||
const chunk = chunkQueue.shift();
|
||||
if (chunk) {
|
||||
yieldedChunks++;
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush decoder at the end
|
||||
const finalText = decoder.decode();
|
||||
if (finalText) {
|
||||
if (rawResponsePreview.length < 500) {
|
||||
rawResponsePreview += finalText.slice(0, 500 - rawResponsePreview.length);
|
||||
}
|
||||
parser.feed(finalText);
|
||||
while (chunkQueue.length > 0) {
|
||||
const chunk = chunkQueue.shift();
|
||||
if (chunk) {
|
||||
yieldedChunks++;
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect silent failures where streaming completed but produced no chunks
|
||||
if (yieldedChunks === 0) {
|
||||
const preview = rawResponsePreview.slice(0, 200);
|
||||
throw new Error(
|
||||
`GitHub Copilot streaming produced no chunks. ` +
|
||||
`Content-Type: ${contentType || "(empty)"}, ` +
|
||||
`Response preview: ${preview || "(empty)"}`
|
||||
);
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort any ongoing polling operation
|
||||
*/
|
||||
|
|
@ -779,6 +475,7 @@ export class GitHubCopilotProvider {
|
|||
this.abortPolling(); // This will abort any ongoing polling operations
|
||||
this.refreshPromise = null;
|
||||
this.refreshAttempts = 0;
|
||||
this.modelPolicyTermsCache.clear();
|
||||
setSettings({
|
||||
githubCopilotAccessToken: "",
|
||||
githubCopilotToken: "",
|
||||
|
|
@ -795,9 +492,11 @@ export class GitHubCopilotProvider {
|
|||
url: MODELS_URL,
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"Copilot-Integration-Id": "vscode-chat",
|
||||
...this.buildCopilotHeaders(token),
|
||||
Accept: "application/json",
|
||||
// Reason: VSCode Copilot uses "model-access" intent when listing models,
|
||||
// which may return additional fields (billing, is_chat_default, etc.)
|
||||
"Openai-Intent": "model-access",
|
||||
},
|
||||
throw: false,
|
||||
});
|
||||
|
|
@ -817,7 +516,26 @@ export class GitHubCopilotProvider {
|
|||
throw new Error(`Failed to list models: ${res.status}`);
|
||||
}
|
||||
|
||||
return this.getRequestUrlJson(res) as GitHubCopilotModelResponse;
|
||||
const result = this.getRequestUrlJson(res) as GitHubCopilotModelResponse;
|
||||
|
||||
// Cache policy terms for each model so they can be surfaced in error messages.
|
||||
this.modelPolicyTermsCache.clear();
|
||||
result.data?.forEach((model) => {
|
||||
if (model.policy?.terms) {
|
||||
this.modelPolicyTermsCache.set(model.id, model.policy.terms);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the policy terms for a model, if available.
|
||||
* Returns the human-readable guidance text (may include Markdown links)
|
||||
* that explains how the user can enable the model on GitHub's settings page.
|
||||
*/
|
||||
getPolicyTerms(modelId: string): string | undefined {
|
||||
return this.modelPolicyTermsCache.get(modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -830,6 +548,24 @@ export class GitHubCopilotProvider {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build headers required for GitHub Copilot API requests.
|
||||
* Public wrapper around the private buildCopilotHeaders method,
|
||||
* exposed for use by ChatOpenAI-based models that inject auth via configuration.fetch.
|
||||
*/
|
||||
buildCopilotRequestHeaders(token: string): Record<string, string> {
|
||||
return this.buildCopilotHeaders(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cached Copilot token so the next request forces a refresh.
|
||||
* Public wrapper around clearCopilotToken, used by the fetch wrapper
|
||||
* to implement "401 → refresh token → retry once" logic.
|
||||
*/
|
||||
invalidateCopilotToken(): void {
|
||||
this.clearCopilotToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort JSON extraction from requestUrl responses, which may return an object or a JSON string.
|
||||
* @param response - Obsidian requestUrl response.
|
||||
|
|
|
|||
|
|
@ -373,10 +373,26 @@ export interface GitHubCopilotModel {
|
|||
model_picker_enabled?: boolean;
|
||||
model_picker_category?: string;
|
||||
preview?: boolean;
|
||||
/** Whether this model is the default for chat. */
|
||||
is_chat_default?: boolean;
|
||||
/** Whether this model is the fallback when premium requests are exhausted. */
|
||||
is_chat_fallback?: boolean;
|
||||
/** Billing info for premium model differentiation. May be absent for legacy models. */
|
||||
billing?: {
|
||||
is_premium: boolean;
|
||||
multiplier: number;
|
||||
restricted_to?: string[];
|
||||
};
|
||||
/** Model availability policy. `state: "disabled"` means user must enable via GitHub settings. */
|
||||
policy?: {
|
||||
state: string;
|
||||
terms?: string;
|
||||
};
|
||||
capabilities?: {
|
||||
family?: string;
|
||||
type?: string;
|
||||
};
|
||||
supported_endpoints?: string[];
|
||||
}
|
||||
|
||||
// Response type mapping
|
||||
|
|
|
|||
|
|
@ -31,6 +31,31 @@ interface ModelImporterProps {
|
|||
credentialVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a verification message with Markdown links converted to <a> elements.
|
||||
* policy.terms uses the format: `[text](url)` which we parse into anchor tags.
|
||||
*/
|
||||
function renderVerificationMessage(message: string): React.ReactNode {
|
||||
// Split message into paragraphs on double newlines
|
||||
const paragraphs = message.split(/\n\n+/);
|
||||
return paragraphs.map((paragraph, i) => {
|
||||
// Convert [text](url) to <a> elements within each paragraph
|
||||
const parts = paragraph.split(/(\[[^\]]+\]\([^)]+\))/g);
|
||||
const nodes = parts.map((part, j) => {
|
||||
const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
||||
if (match) {
|
||||
return (
|
||||
<a key={j} href={match[2]} target="_blank" rel="noopener noreferrer" className="tw-underline">
|
||||
{match[1]}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
});
|
||||
return <p key={i} className={i > 0 ? "tw-mt-1" : ""}>{nodes}</p>;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable component for selecting and adding models from a provider
|
||||
*/
|
||||
|
|
@ -47,6 +72,7 @@ export function ModelImporter({
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedModel, setSelectedModel] = useState<StandardModel | null>(null);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [verificationMessage, setVerificationMessage] = useState<string | null>(null);
|
||||
|
||||
// Use ref to track loading state to avoid stale closure in useCallback
|
||||
const loadingRef = useRef(false);
|
||||
|
|
@ -86,6 +112,7 @@ export function ModelImporter({
|
|||
setModels(null);
|
||||
setSelectedModel(null);
|
||||
setError(null);
|
||||
setVerificationMessage(null);
|
||||
}, [provider, credentialVersion]);
|
||||
|
||||
// Auto-load models when expanded and ready
|
||||
|
|
@ -102,6 +129,7 @@ export function ModelImporter({
|
|||
}
|
||||
|
||||
setVerifying(true);
|
||||
setVerificationMessage(null);
|
||||
|
||||
try {
|
||||
const result = await verifyAndAddModel(
|
||||
|
|
@ -112,6 +140,7 @@ export function ModelImporter({
|
|||
|
||||
if (result.alreadyExists) {
|
||||
if (result.verificationFailed) {
|
||||
setVerificationMessage(result.verificationError ?? null);
|
||||
new Notice(
|
||||
`Model ${selectedModel.name} already exists (verification failed: ${result.verificationError})`,
|
||||
10000
|
||||
|
|
@ -122,7 +151,6 @@ export function ModelImporter({
|
|||
);
|
||||
}
|
||||
} else {
|
||||
// Add the model
|
||||
const customModel = buildCustomModel({
|
||||
id: selectedModel.id,
|
||||
name: selectedModel.name,
|
||||
|
|
@ -132,6 +160,7 @@ export function ModelImporter({
|
|||
updateSetting("activeModels", updatedModels);
|
||||
|
||||
if (result.verificationFailed) {
|
||||
setVerificationMessage(result.verificationError ?? null);
|
||||
new Notice(
|
||||
`Model ${selectedModel.name} added (verification failed: ${result.verificationError})`,
|
||||
10000
|
||||
|
|
@ -172,6 +201,7 @@ export function ModelImporter({
|
|||
const model = models?.find((m) => m.id === value);
|
||||
if (model) {
|
||||
setSelectedModel(model);
|
||||
setVerificationMessage(null);
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
|
|
@ -205,6 +235,11 @@ export function ModelImporter({
|
|||
{models === null && !loading && !error && (
|
||||
<div className="tw-p-1 tw-text-muted">Click to load available models.</div>
|
||||
)}
|
||||
{verificationMessage && (
|
||||
<div className="tw-mt-2 tw-rounded-md tw-border tw-border-border tw-p-2 tw-text-error tw-bg-modifier-error/10">
|
||||
{renderVerificationMessage(verificationMessage)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
|
|
|
|||
|
|
@ -151,6 +151,21 @@ export async function verifyAndAddModel(
|
|||
} catch (error) {
|
||||
verificationFailed = true;
|
||||
verificationError = err2String(error);
|
||||
|
||||
// For GitHub Copilot models, a "not supported" 400 typically means the user
|
||||
// hasn't enabled this model on their GitHub settings page. Append the policy
|
||||
// terms (which include an activation link) to guide the user.
|
||||
if (
|
||||
customModel.provider === ChatModelProviders.GITHUB_COPILOT &&
|
||||
verificationError.toLowerCase().includes("not supported")
|
||||
) {
|
||||
// Reason: policy cache is keyed by model.id, not customModel.name (display name)
|
||||
const terms = GitHubCopilotProvider.getInstance().getPolicyTerms(model.id);
|
||||
if (terms) {
|
||||
verificationError += `\n\n${terms}`;
|
||||
}
|
||||
}
|
||||
|
||||
logError("Model verification failed:", error);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue