From 11f38782447ba48884334b0632abb6a168f0ab9a Mon Sep 17 00:00:00 2001 From: Emt-lin <41323133+Emt-lin@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:46:53 +0800 Subject: [PATCH] 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 --- src/LLMProviders/chatModelManager.ts | 2 + .../githubCopilot/GitHubCopilotChatModel.ts | 400 ++++++++---------- .../githubCopilot/GitHubCopilotProvider.ts | 382 +++-------------- src/settings/providerModels.ts | 16 + src/settings/v2/components/ModelImporter.tsx | 37 +- src/settings/v2/utils/modelActions.ts | 15 + 6 files changed, 311 insertions(+), 541 deletions(-) diff --git a/src/LLMProviders/chatModelManager.ts b/src/LLMProviders/chatModelManager.ts index 3ce55b8b..21860421 100644 --- a/src/LLMProviders/chatModelManager.ts +++ b/src/LLMProviders/chatModelManager.ts @@ -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, }, }; diff --git a/src/LLMProviders/githubCopilot/GitHubCopilotChatModel.ts b/src/LLMProviders/githubCopilot/GitHubCopilotChatModel.ts index 7f8b754e..69a94c44 100644 --- a/src/LLMProviders/githubCopilot/GitHubCopilotChatModel.ts +++ b/src/LLMProviders/githubCopilot/GitHubCopilotChatModel.ts @@ -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[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 { + return async (input: RequestInfo | URL, init: RequestInit = {}): Promise => { + // 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 => { + 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, + 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 { - 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 { - // 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 = {}; - 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 { + override async getNumTokens(content: MessageContent): Promise { const text = extractTextFromChunk(content); if (!text) return 0; return Math.ceil(text.length / CHARS_PER_TOKEN); diff --git a/src/LLMProviders/githubCopilot/GitHubCopilotProvider.ts b/src/LLMProviders/githubCopilot/GitHubCopilotProvider.ts index 5fc15eb2..61807e10 100644 --- a/src/LLMProviders/githubCopilot/GitHubCopilotProvider.ts +++ b/src/LLMProviders/githubCopilot/GitHubCopilotProvider.ts @@ -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 = { - 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 | 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(); /** * 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 - ): Promise { - 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 { - 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; - const nestedError = record.error; - if (nestedError && typeof nestedError === "object") { - const nestedRecord = nestedError as Record; - if (typeof nestedRecord.message === "string") { - errorDetail = nestedRecord.message; - } - } - if (!errorDetail && typeof record.message === "string") { - errorDetail = record.message; - } - if (!errorDetail) { - try { - errorDetail = JSON.stringify(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).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 { - 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 { + 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. diff --git a/src/settings/providerModels.ts b/src/settings/providerModels.ts index 3b072bc8..2319affd 100644 --- a/src/settings/providerModels.ts +++ b/src/settings/providerModels.ts @@ -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 diff --git a/src/settings/v2/components/ModelImporter.tsx b/src/settings/v2/components/ModelImporter.tsx index ff99cc1a..3985d5d5 100644 --- a/src/settings/v2/components/ModelImporter.tsx +++ b/src/settings/v2/components/ModelImporter.tsx @@ -31,6 +31,31 @@ interface ModelImporterProps { credentialVersion?: string; } +/** + * Render a verification message with Markdown links converted to 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 elements within each paragraph + const parts = paragraph.split(/(\[[^\]]+\]\([^)]+\))/g); + const nodes = parts.map((part, j) => { + const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/); + if (match) { + return ( + + {match[1]} + + ); + } + return part; + }); + return

0 ? "tw-mt-1" : ""}>{nodes}

; + }); +} + /** * Reusable component for selecting and adding models from a provider */ @@ -47,6 +72,7 @@ export function ModelImporter({ const [error, setError] = useState(null); const [selectedModel, setSelectedModel] = useState(null); const [verifying, setVerifying] = useState(false); + const [verificationMessage, setVerificationMessage] = useState(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 && (
Click to load available models.
)} + {verificationMessage && ( +
+ {renderVerificationMessage(verificationMessage)} +
+ )} diff --git a/src/settings/v2/utils/modelActions.ts b/src/settings/v2/utils/modelActions.ts index ecdf747f..758fab64 100644 --- a/src/settings/v2/utils/modelActions.ts +++ b/src/settings/v2/utils/modelActions.ts @@ -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); } }