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:
Emt-lin 2026-03-06 01:46:53 +08:00 committed by GitHub
parent f51036fa8a
commit 11f3878244
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 311 additions and 541 deletions

View file

@ -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,
},
};

View file

@ -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);

View file

@ -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.

View file

@ -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

View file

@ -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>

View file

@ -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);
}
}