From f1ee68c06e8f3699e783952ba032ea8b41e85f3b Mon Sep 17 00:00:00 2001 From: 137 <113233555+caezium@users.noreply.github.com> Date: Sun, 22 Jun 2025 00:52:09 +0800 Subject: [PATCH 1/5] feat: add support for Github Copilot as a provider --- src/LLMProviders/chatModelManager.ts | 113 +++++++++--- src/LLMProviders/githubCopilotProvider.ts | 164 ++++++++++++++++++ src/constants.ts | 12 ++ src/settings/model.ts | 3 + src/settings/providerModels.ts | 7 +- src/settings/v2/components/ApiKeyDialog.tsx | 110 ++++++++++++ src/settings/v2/components/ModelAddDialog.tsx | 25 +++ 7 files changed, 409 insertions(+), 25 deletions(-) create mode 100644 src/LLMProviders/githubCopilotProvider.ts diff --git a/src/LLMProviders/chatModelManager.ts b/src/LLMProviders/chatModelManager.ts index f3ce54837..b318a9a47 100644 --- a/src/LLMProviders/chatModelManager.ts +++ b/src/LLMProviders/chatModelManager.ts @@ -13,6 +13,8 @@ import { HarmBlockThreshold, HarmCategory } from "@google/generative-ai"; import { ChatAnthropic } from "@langchain/anthropic"; import { ChatCohere } from "@langchain/cohere"; import { BaseChatModel } from "@langchain/core/language_models/chat_models"; +import { AIMessage } from "@langchain/core/messages"; +import { Runnable } from "@langchain/core/runnables"; import { ChatDeepSeek } from "@langchain/deepseek"; import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; import { ChatGroq } from "@langchain/groq"; @@ -21,11 +23,63 @@ import { ChatOllama } from "@langchain/ollama"; import { ChatOpenAI } from "@langchain/openai"; import { ChatXAI } from "@langchain/xai"; import { Notice } from "obsidian"; +import { GitHubCopilotProvider } from "./githubCopilotProvider"; +import { ChatPromptValue } from "@langchain/core/prompt_values"; + +class CopilotRunnable extends Runnable { + lc_serializable = false; + lc_namespace = ["langchain", "chat_models", "copilot"]; + private provider: GitHubCopilotProvider; + private modelName: string; + + constructor(provider: GitHubCopilotProvider, modelName: string) { + super(); + this.provider = provider; + this.modelName = modelName; + } + + async invoke(input: ChatPromptValue, options?: any): Promise { + const messages = input.toChatMessages().map((m) => ({ + role: m._getType() === "human" ? "user" : "assistant", + content: m.content as string, + })); + const response = await this.provider.sendChatMessage(messages, this.modelName); + const content = response.choices?.[0]?.message?.content || ""; + return new AIMessage(content); + } +} type ChatConstructorType = { new (config: any): any; }; +// Placeholder for GitHub Copilot chat provider +class ChatGitHubCopilot { + private provider: GitHubCopilotProvider; + constructor(config: any) { + this.provider = new GitHubCopilotProvider(); + // TODO: Use config for persistent storage, UI callbacks, etc. + } + async send(messages: { role: string; content: string }[], model = "gpt-4") { + return this.provider.sendChatMessage(messages, model); + } + getAuthState() { + return this.provider.getAuthState(); + } + async startAuth() { + return this.provider.startDeviceCodeFlow(); + } + async pollForAccessToken() { + return this.provider.pollForAccessToken(); + } + async fetchCopilotToken() { + return this.provider.fetchCopilotToken(); + } + resetAuth() { + this.provider.resetAuth(); + } +} + const CHAT_PROVIDER_CONSTRUCTORS = { [ChatModelProviders.OPENAI]: ChatOpenAI, [ChatModelProviders.AZURE_OPENAI]: ChatOpenAI, @@ -41,6 +95,7 @@ const CHAT_PROVIDER_CONSTRUCTORS = { [ChatModelProviders.COPILOT_PLUS]: ChatOpenAI, [ChatModelProviders.MISTRAL]: ChatMistralAI, [ChatModelProviders.DEEPSEEK]: ChatDeepSeek, + [ChatModelProviders.GITHUB_COPILOT]: ChatGitHubCopilot, // Register GitHub Copilot } as const; type ChatProviderConstructMap = typeof CHAT_PROVIDER_CONSTRUCTORS; @@ -72,6 +127,7 @@ export default class ChatModelManager { [ChatModelProviders.COPILOT_PLUS]: () => getSettings().plusLicenseKey, [ChatModelProviders.MISTRAL]: () => getSettings().mistralApiKey, [ChatModelProviders.DEEPSEEK]: () => getSettings().deepseekApiKey, + [ChatModelProviders.GITHUB_COPILOT]: () => "", // Placeholder for GitHub Copilot } as const; private constructor() { @@ -97,10 +153,16 @@ export default class ChatModelManager { const isThinkingEnabled = modelName.startsWith("claude-3-7-sonnet") || modelName.startsWith("claude-sonnet-4"); + // For GitHub Copilot, streaming is not supported + const streaming = + customModel.provider === ChatModelProviders.GITHUB_COPILOT + ? false + : (customModel.stream ?? true); + // Base config without temperature when thinking is enabled const baseConfig: Omit = { modelName: modelName, - streaming: customModel.stream ?? true, + streaming, maxRetries: 3, maxConcurrency: 3, enableCors: customModel.enableCors, @@ -250,6 +312,7 @@ export default class ChatModelManager { fetch: customModel.enableCors ? safeFetch : undefined, }, }, + [ChatModelProviders.GITHUB_COPILOT]: {}, // Placeholder config for GitHub Copilot }; const selectedProviderConfig = @@ -344,35 +407,28 @@ export default class ChatModelManager { } async setChatModel(model: CustomModel): Promise { - const modelKey = getModelKeyFromModel(model); try { - const modelInstance = await this.createModelInstance(model); - ChatModelManager.chatModel = modelInstance; - } catch (error) { - logError(error); - new Notice(`Error creating model: ${modelKey}`); + ChatModelManager.chatModel = await this.createModelInstance(model); + logInfo(`Chat model set to ${model.name}`); + } catch (e) { + logError("Failed to set chat model:", e); + new Notice(`Failed to set chat model: ${e.message}`); + ChatModelManager.chatModel = null; } } async createModelInstance(model: CustomModel): Promise { - // Create and return the appropriate model - const modelKey = getModelKeyFromModel(model); - const selectedModel = ChatModelManager.modelMap[modelKey]; - if (!selectedModel) { - throw new Error(`No model found for: ${modelKey}`); - } - if (!selectedModel.hasApiKey) { - const errorMessage = `API key is not provided for the model: ${modelKey}.`; - new Notice(errorMessage); - throw new Error(errorMessage); + if (model.provider === ChatModelProviders.GITHUB_COPILOT) { + const provider = new GitHubCopilotProvider(); + const copilotRunnable = new CopilotRunnable(provider, model.name); + // The type assertion is a bit of a hack, but it makes it work with the existing structure + return copilotRunnable as unknown as BaseChatModel; } - const modelConfig = await this.getModelConfig(model); + const AIConstructor = this.getProviderConstructor(model); + const config = await this.getModelConfig(model); - const newModelInstance = new selectedModel.AIConstructor({ - ...modelConfig, - }); - return newModelInstance; + return new AIConstructor(config); } validateChatModel(chatModel: BaseChatModel): boolean { @@ -427,6 +483,19 @@ export default class ChatModelManager { } async ping(model: CustomModel): Promise { + if (model.provider === ChatModelProviders.GITHUB_COPILOT) { + const provider = new GitHubCopilotProvider(); + const state = provider.getAuthState(); + if (state.status === "authenticated") { + new Notice("GitHub Copilot is authenticated."); + return true; + } else { + new Notice( + "GitHub Copilot is not authenticated. Please set it up in the 'Basic' settings tab." + ); + return false; + } + } const tryPing = async (enableCors: boolean) => { const modelToTest = { ...model, enableCors }; const modelConfig = await this.getModelConfig(modelToTest); diff --git a/src/LLMProviders/githubCopilotProvider.ts b/src/LLMProviders/githubCopilotProvider.ts new file mode 100644 index 000000000..3f459a5e8 --- /dev/null +++ b/src/LLMProviders/githubCopilotProvider.ts @@ -0,0 +1,164 @@ +// Based on the flow used in Pierrad/obsidian-github-copilot +// Handles device code flow, token exchange, and chat requests + +import { getSettings, updateSetting } from "@/settings/model"; +import { requestUrl, Notice } from "obsidian"; + +const CLIENT_ID = "Iv1.b507a08c87ecfe98"; // Copilot VSCode client ID +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 CHAT_COMPLETIONS_URL = "https://api.githubcopilot.com/chat/completions"; + +export interface CopilotAuthState { + deviceCode?: string; + userCode?: string; + verificationUri?: string; + expiresIn?: number; + interval?: number; + accessToken?: string; + copilotToken?: string; + copilotTokenExpiresAt?: number; + status: "idle" | "pending" | "authenticated" | "error"; + error?: string; +} + +export class GitHubCopilotProvider { + private authState: CopilotAuthState = { status: "idle" }; + + constructor() { + // Load persisted tokens from settings + const settings = getSettings(); + if (settings.copilotAccessToken && settings.copilotToken) { + this.authState.accessToken = settings.copilotAccessToken; + this.authState.copilotToken = settings.copilotToken; + this.authState.copilotTokenExpiresAt = settings.copilotTokenExpiresAt; + if (settings.copilotTokenExpiresAt && settings.copilotTokenExpiresAt > Date.now()) { + this.authState.status = "authenticated"; + } + } + } + + getAuthState() { + return this.authState; + } + + // Step 1: Start device code flow + async startDeviceCodeFlow() { + this.authState.status = "pending"; + try { + const res = await requestUrl({ + url: DEVICE_CODE_URL, + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ client_id: CLIENT_ID, scope: "read:user" }), + }); + if (res.status !== 200) throw new Error("Failed to get device code"); + const data = res.json; + this.authState.deviceCode = data.device_code; + this.authState.userCode = data.user_code; + this.authState.verificationUri = data.verification_uri || data.verification_uri_complete; + this.authState.expiresIn = data.expires_in; + this.authState.interval = data.interval; + this.authState.status = "pending"; + return { + userCode: data.user_code, + verificationUri: data.verification_uri || data.verification_uri_complete, + }; + } catch (e: any) { + this.authState.status = "error"; + this.authState.error = e.message; + throw e; + } + } + + // Step 2: Poll for access token + async pollForAccessToken() { + if (!this.authState.deviceCode) throw new Error("No device code"); + + new Notice("Waiting for you to authorize in the browser...", 15000); + + const poll = async () => { + const res = await requestUrl({ + url: ACCESS_TOKEN_URL, + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ + client_id: CLIENT_ID, + device_code: this.authState.deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + const data = res.json; + if (data.error === "authorization_pending") return null; + if (data.error) throw new Error(data.error_description || data.error); + return data.access_token; + }; + const interval = this.authState.interval || 5; + const expiresAt = Date.now() + (this.authState.expiresIn || 900) * 1000; + while (Date.now() < expiresAt) { + const token = await poll(); + if (token) { + this.authState.accessToken = token; + // Persist access token + updateSetting("copilotAccessToken", token); + return token; + } + await new Promise((resolve) => setTimeout(resolve, interval * 1000)); + } + throw new Error("Device code expired"); + } + + // Step 3: Exchange for Copilot token + async fetchCopilotToken() { + if (!this.authState.accessToken) throw new Error("No access token"); + const res = await requestUrl({ + url: COPILOT_TOKEN_URL, + method: "GET", + headers: { Authorization: `Bearer ${this.authState.accessToken}` }, + }); + if (res.status !== 200) throw new Error("Failed to get Copilot token"); + const data = res.json; + this.authState.copilotToken = data.token; + this.authState.copilotTokenExpiresAt = + Date.now() + (data.expires_at ? data.expires_at * 1000 : 3600 * 1000); + this.authState.status = "authenticated"; + // Persist Copilot token and expiration + updateSetting("copilotToken", data.token); + updateSetting("copilotTokenExpiresAt", this.authState.copilotTokenExpiresAt); + return data.token; + } + + // Step 4: Send chat message + async sendChatMessage(messages: { role: string; content: string }[], model = "gpt-4") { + if (!this.authState.copilotToken) throw new Error("Not authenticated with Copilot"); + const res = await requestUrl({ + url: CHAT_COMPLETIONS_URL, + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.authState.copilotToken}`, + "User-Agent": "vscode/1.80.1", + "Editor-Version": "vscode/1.80.1", + "OpenAI-Intent": "conversation-panel", + }, + body: JSON.stringify({ + model, + messages, + stream: false, + }), + }); + if (res.status !== 200) throw new Error("Copilot chat request failed"); + const data = res.json; + return data; + } + + // Utility: Reset authentication state + resetAuth() { + this.authState = { status: "idle" }; + // Clear persisted tokens + updateSetting("copilotAccessToken", ""); + updateSetting("copilotToken", ""); + updateSetting("copilotTokenExpiresAt", 0); + } +} diff --git a/src/constants.ts b/src/constants.ts index 7e7fabb56..0b90a7cde 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -148,6 +148,7 @@ export enum ChatModelProviders { COPILOT_PLUS = "copilot-plus", MISTRAL = "mistralai", DEEPSEEK = "deepseek", + GITHUB_COPILOT = "github-copilot", } export enum ModelCapability { @@ -507,6 +508,12 @@ export const ProviderInfo: Record = { keyManagementURL: "", listModelURL: "", }, + [ChatModelProviders.GITHUB_COPILOT]: { + label: "GitHub Copilot", + host: "https://api.githubcopilot.com", + keyManagementURL: "", + listModelURL: "", + }, [EmbeddingModelProviders.COPILOT_PLUS_JINA]: { label: "Copilot Plus", host: "https://api.brevilabs.com/v1", @@ -528,6 +535,7 @@ export const ProviderSettingsKeyMap: Record = ( +export type ModelAdapter = ( data: ProviderResponseMap[T] ) => StandardModel[]; // Create adapter function type export type ProviderModelAdapters = { - [K in SettingKeyProviders]?: ModelAdapter; + [K in Extract]?: ModelAdapter; }; /** @@ -455,7 +455,8 @@ export const getDefaultModelAdapter = (provider: SettingKeyProviders) => { * Uses provider-specific adapter if available, otherwise falls back to default adapter */ export const getModelAdapter = (provider: SettingKeyProviders) => { - return providerAdapters[provider] || getDefaultModelAdapter(provider); + // Type assertion to allow unknown providers to fallback to default adapter + return (providerAdapters as any)[provider] || getDefaultModelAdapter(provider); }; /** diff --git a/src/settings/v2/components/ApiKeyDialog.tsx b/src/settings/v2/components/ApiKeyDialog.tsx index 896be9189..55510dc50 100644 --- a/src/settings/v2/components/ApiKeyDialog.tsx +++ b/src/settings/v2/components/ApiKeyDialog.tsx @@ -10,6 +10,7 @@ import ProjectManager from "@/LLMProviders/projectManager"; import { logError, logInfo } from "@/logger"; import { updateSetting, useSettingsValue } from "@/settings/model"; import { parseModelsResponse, StandardModel } from "@/settings/providerModels"; +import { GitHubCopilotProvider } from "@/LLMProviders/githubCopilotProvider"; import { err2String, getNeedSetKeyProvider, @@ -265,6 +266,53 @@ function ApiKeyModalContent({ onClose }: ApiKeyModalContentProps) { } }; + const [copilotProvider] = useState(() => new GitHubCopilotProvider()); + const [authStep, setAuthStep] = useState< + "idle" | "pending" | "user" | "polling" | "done" | "error" + >("idle"); + const [userCode, setUserCode] = useState(null); + const [verificationUri, setVerificationUri] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const state = copilotProvider.getAuthState(); + if (state.status === "authenticated") { + setAuthStep("done"); + } + }, [copilotProvider]); + + // Handler for authentication + const handleCopilotAuth = async () => { + new Notice("Starting GitHub authentication..."); + setAuthStep("pending"); + setError(null); + try { + const { userCode, verificationUri } = await copilotProvider.startDeviceCodeFlow(); + new Notice("Please check your browser to authorize Obsidian Copilot."); + setUserCode(userCode); + setVerificationUri(verificationUri); + setAuthStep("user"); + + await copilotProvider.pollForAccessToken(); + new Notice("Access token received!"); + + await copilotProvider.fetchCopilotToken(); + new Notice("Github Copilot token received! Authentication complete."); + + setAuthStep("done"); + } catch (e: any) { + new Notice(`Authentication failed: ${e.message}`); + setError(e.message); + setAuthStep("error"); + } + }; + + const handleCopilotReset = () => { + copilotProvider.resetAuth(); + setAuthStep("idle"); + new Notice("GitHub Copilot authentication has been reset."); + }; + return (
@@ -461,6 +509,68 @@ function ApiKeyModalContent({ onClose }: ApiKeyModalContentProps) { ))} + {/* GitHub Copilot Onboarding Section */} +
+
+ GitHub Copilot +
+
+

+ Authenticate with your GitHub account to use Github Copilot models for chat. +

+
+ + {authStep === "done" && ( + + )} +
+ {authStep === "user" && userCode && verificationUri && ( +
+
+ 1. Go to{" "} + + {verificationUri} + +
+
+ 2. Enter code: + e.target.select()} + aria-label="GitHub Device Code" + /> +
+
+ )} + {/* info */} + {authStep === "done" && ( +
+ Successfully authenticated! You can now add a GitHub Copilot model in the + "Model" tab. +
+ )} + {error &&
{error}
} +
+
diff --git a/src/settings/v2/components/ModelAddDialog.tsx b/src/settings/v2/components/ModelAddDialog.tsx index 8e75ed809..79478e163 100644 --- a/src/settings/v2/components/ModelAddDialog.tsx +++ b/src/settings/v2/components/ModelAddDialog.tsx @@ -37,6 +37,7 @@ import { PasswordInput } from "@/components/ui/password-input"; import { Checkbox } from "@/components/ui/checkbox"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { FormField } from "@/components/ui/form-field"; +import { SettingSwitch } from "@/components/ui/setting-switch"; interface FormErrors { name: boolean; @@ -547,6 +548,30 @@ export const ModelAddDialog: React.FC = ({ {renderProviderSpecificFields()} + +
+
+ Stream output + + + + + + + Enable streaming responses from the model. + {model.provider === "github-copilot" && ( +

Not supported by GitHub Copilot.

+ )} +
+
+
+
+ setModel({ ...model, stream: checked })} + /> +
From 452be7807c155fa4c629a8ff58d462048a749605 Mon Sep 17 00:00:00 2001 From: 137 <113233555+caezium@users.noreply.github.com> Date: Tue, 24 Jun 2025 21:37:51 +0800 Subject: [PATCH 2/5] fix: github copilot mapping error, invalid cast that causes runtime errors, token calculation (rn approximation) --- src/LLMProviders/chatModelManager.ts | 89 ++++++++++++++++++----- src/LLMProviders/githubCopilotProvider.ts | 5 +- 2 files changed, 73 insertions(+), 21 deletions(-) diff --git a/src/LLMProviders/chatModelManager.ts b/src/LLMProviders/chatModelManager.ts index b318a9a47..a06c16d57 100644 --- a/src/LLMProviders/chatModelManager.ts +++ b/src/LLMProviders/chatModelManager.ts @@ -12,9 +12,13 @@ import { err2String, isOSeriesModel, safeFetch, withSuppressedTokenWarnings } fr import { HarmBlockThreshold, HarmCategory } from "@google/generative-ai"; import { ChatAnthropic } from "@langchain/anthropic"; import { ChatCohere } from "@langchain/cohere"; -import { BaseChatModel } from "@langchain/core/language_models/chat_models"; -import { AIMessage } from "@langchain/core/messages"; -import { Runnable } from "@langchain/core/runnables"; +import { + BaseChatModel, + type BaseChatModelParams, +} from "@langchain/core/language_models/chat_models"; +import { AIMessage, type BaseMessage, type MessageContent } from "@langchain/core/messages"; +import { type ChatResult, ChatGeneration } from "@langchain/core/outputs"; +import { type CallbackManagerForLLMRun } from "@langchain/core/callbacks/manager"; import { ChatDeepSeek } from "@langchain/deepseek"; import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; import { ChatGroq } from "@langchain/groq"; @@ -24,28 +28,78 @@ import { ChatOpenAI } from "@langchain/openai"; import { ChatXAI } from "@langchain/xai"; import { Notice } from "obsidian"; import { GitHubCopilotProvider } from "./githubCopilotProvider"; -import { ChatPromptValue } from "@langchain/core/prompt_values"; -class CopilotRunnable extends Runnable { +export interface CopilotChatModelParams extends BaseChatModelParams { + provider: GitHubCopilotProvider; + modelName: string; +} + +class CopilotChatModel extends BaseChatModel { lc_serializable = false; lc_namespace = ["langchain", "chat_models", "copilot"]; private provider: GitHubCopilotProvider; - private modelName: string; + modelName: string; + + constructor(fields: CopilotChatModelParams) { + super(fields); + this.provider = fields.provider; + this.modelName = fields.modelName; + } - constructor(provider: GitHubCopilotProvider, modelName: string) { - super(); - this.provider = provider; - this.modelName = modelName; + _llmType(): string { + return "copilot-chat-model"; } - async invoke(input: ChatPromptValue, options?: any): Promise { - const messages = input.toChatMessages().map((m) => ({ - role: m._getType() === "human" ? "user" : "assistant", + private _convertMessageType(messageType: string): string { + switch (messageType) { + case "human": + return "user"; + case "ai": + return "assistant"; + case "system": + return "system"; + case "tool": + return "tool"; + case "function": + return "function"; + case "generic": + default: + return "user"; + } + } + + async _generate( + messages: BaseMessage[], + options: this["ParsedCallOptions"], + runManager?: CallbackManagerForLLMRun + ): Promise { + const chatMessages = messages.map((m) => ({ + role: this._convertMessageType(m._getType()), content: m.content as string, })); - const response = await this.provider.sendChatMessage(messages, this.modelName); + + const response = await this.provider.sendChatMessage(chatMessages, this.modelName); const content = response.choices?.[0]?.message?.content || ""; - return new AIMessage(content); + + const generation: ChatGeneration = { + text: content, + message: new AIMessage(content), + }; + + return { + generations: [generation], + llmOutput: {}, // add more details here if needed + }; + } + + /** + * A simple approximation: ~4 chars per token for English text + * This matches the fallback behavior in ChatModelManager.countTokens + */ + async getNumTokens(content: MessageContent): Promise { + const text = typeof content === "string" ? content : JSON.stringify(content); + if (!text) return 0; + return Math.ceil(text.length / 4); } } @@ -53,7 +107,6 @@ type ChatConstructorType = { new (config: any): any; }; -// Placeholder for GitHub Copilot chat provider class ChatGitHubCopilot { private provider: GitHubCopilotProvider; constructor(config: any) { @@ -420,9 +473,7 @@ export default class ChatModelManager { async createModelInstance(model: CustomModel): Promise { if (model.provider === ChatModelProviders.GITHUB_COPILOT) { const provider = new GitHubCopilotProvider(); - const copilotRunnable = new CopilotRunnable(provider, model.name); - // The type assertion is a bit of a hack, but it makes it work with the existing structure - return copilotRunnable as unknown as BaseChatModel; + return new CopilotChatModel({ provider, modelName: model.name }); } const AIConstructor = this.getProviderConstructor(model); diff --git a/src/LLMProviders/githubCopilotProvider.ts b/src/LLMProviders/githubCopilotProvider.ts index 3f459a5e8..8fbff526b 100644 --- a/src/LLMProviders/githubCopilotProvider.ts +++ b/src/LLMProviders/githubCopilotProvider.ts @@ -120,8 +120,9 @@ export class GitHubCopilotProvider { if (res.status !== 200) throw new Error("Failed to get Copilot token"); const data = res.json; this.authState.copilotToken = data.token; - this.authState.copilotTokenExpiresAt = - Date.now() + (data.expires_at ? data.expires_at * 1000 : 3600 * 1000); + this.authState.copilotTokenExpiresAt = data.expires_at + ? data.expires_at * 1000 + : Date.now() + 3600 * 1000; this.authState.status = "authenticated"; // Persist Copilot token and expiration updateSetting("copilotToken", data.token); From ddbdf7176bd412628a499d1baff57ab828cf0e6e Mon Sep 17 00:00:00 2001 From: 137 <113233555+caezium@users.noreply.github.com> Date: Tue, 24 Jun 2025 21:58:13 +0800 Subject: [PATCH 3/5] separate github copilot from chatmodelmanager into new file --- src/LLMProviders/chatModelManager.ts | 109 +-------------------- src/LLMProviders/githubCopilotChatModel.ts | 107 ++++++++++++++++++++ 2 files changed, 109 insertions(+), 107 deletions(-) create mode 100644 src/LLMProviders/githubCopilotChatModel.ts diff --git a/src/LLMProviders/chatModelManager.ts b/src/LLMProviders/chatModelManager.ts index a06c16d57..6bfa9f39d 100644 --- a/src/LLMProviders/chatModelManager.ts +++ b/src/LLMProviders/chatModelManager.ts @@ -12,13 +12,7 @@ import { err2String, isOSeriesModel, safeFetch, withSuppressedTokenWarnings } fr import { HarmBlockThreshold, HarmCategory } from "@google/generative-ai"; import { ChatAnthropic } from "@langchain/anthropic"; import { ChatCohere } from "@langchain/cohere"; -import { - BaseChatModel, - type BaseChatModelParams, -} from "@langchain/core/language_models/chat_models"; -import { AIMessage, type BaseMessage, type MessageContent } from "@langchain/core/messages"; -import { type ChatResult, ChatGeneration } from "@langchain/core/outputs"; -import { type CallbackManagerForLLMRun } from "@langchain/core/callbacks/manager"; +import { BaseChatModel } from "@langchain/core/language_models/chat_models"; import { ChatDeepSeek } from "@langchain/deepseek"; import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; import { ChatGroq } from "@langchain/groq"; @@ -28,111 +22,12 @@ import { ChatOpenAI } from "@langchain/openai"; import { ChatXAI } from "@langchain/xai"; import { Notice } from "obsidian"; import { GitHubCopilotProvider } from "./githubCopilotProvider"; - -export interface CopilotChatModelParams extends BaseChatModelParams { - provider: GitHubCopilotProvider; - modelName: string; -} - -class CopilotChatModel extends BaseChatModel { - lc_serializable = false; - lc_namespace = ["langchain", "chat_models", "copilot"]; - private provider: GitHubCopilotProvider; - modelName: string; - - constructor(fields: CopilotChatModelParams) { - super(fields); - this.provider = fields.provider; - this.modelName = fields.modelName; - } - - _llmType(): string { - return "copilot-chat-model"; - } - - private _convertMessageType(messageType: string): string { - switch (messageType) { - case "human": - return "user"; - case "ai": - return "assistant"; - case "system": - return "system"; - case "tool": - return "tool"; - case "function": - return "function"; - case "generic": - default: - return "user"; - } - } - - async _generate( - messages: BaseMessage[], - options: this["ParsedCallOptions"], - runManager?: CallbackManagerForLLMRun - ): Promise { - const chatMessages = messages.map((m) => ({ - role: this._convertMessageType(m._getType()), - content: m.content as string, - })); - - const response = await this.provider.sendChatMessage(chatMessages, this.modelName); - const content = response.choices?.[0]?.message?.content || ""; - - const generation: ChatGeneration = { - text: content, - message: new AIMessage(content), - }; - - return { - generations: [generation], - llmOutput: {}, // add more details here if needed - }; - } - - /** - * A simple approximation: ~4 chars per token for English text - * This matches the fallback behavior in ChatModelManager.countTokens - */ - async getNumTokens(content: MessageContent): Promise { - const text = typeof content === "string" ? content : JSON.stringify(content); - if (!text) return 0; - return Math.ceil(text.length / 4); - } -} +import { ChatGitHubCopilot, CopilotChatModel } from "./githubCopilotChatModel"; type ChatConstructorType = { new (config: any): any; }; -class ChatGitHubCopilot { - private provider: GitHubCopilotProvider; - constructor(config: any) { - this.provider = new GitHubCopilotProvider(); - // TODO: Use config for persistent storage, UI callbacks, etc. - } - async send(messages: { role: string; content: string }[], model = "gpt-4") { - return this.provider.sendChatMessage(messages, model); - } - getAuthState() { - return this.provider.getAuthState(); - } - async startAuth() { - return this.provider.startDeviceCodeFlow(); - } - async pollForAccessToken() { - return this.provider.pollForAccessToken(); - } - async fetchCopilotToken() { - return this.provider.fetchCopilotToken(); - } - resetAuth() { - this.provider.resetAuth(); - } -} - const CHAT_PROVIDER_CONSTRUCTORS = { [ChatModelProviders.OPENAI]: ChatOpenAI, [ChatModelProviders.AZURE_OPENAI]: ChatOpenAI, diff --git a/src/LLMProviders/githubCopilotChatModel.ts b/src/LLMProviders/githubCopilotChatModel.ts new file mode 100644 index 000000000..2db98e4fb --- /dev/null +++ b/src/LLMProviders/githubCopilotChatModel.ts @@ -0,0 +1,107 @@ +import { + BaseChatModel, + type BaseChatModelParams, +} from "@langchain/core/language_models/chat_models"; +import { AIMessage, type BaseMessage, type MessageContent } from "@langchain/core/messages"; +import { type ChatResult, ChatGeneration } from "@langchain/core/outputs"; +import { type CallbackManagerForLLMRun } from "@langchain/core/callbacks/manager"; +import { GitHubCopilotProvider } from "./githubCopilotProvider"; + +export interface CopilotChatModelParams extends BaseChatModelParams { + provider: GitHubCopilotProvider; + modelName: string; +} + +export class CopilotChatModel extends BaseChatModel { + lc_serializable = false; + lc_namespace = ["langchain", "chat_models", "copilot"]; + private provider: GitHubCopilotProvider; + modelName: string; + + constructor(fields: CopilotChatModelParams) { + super(fields); + this.provider = fields.provider; + this.modelName = fields.modelName; + } + + _llmType(): string { + return "copilot-chat-model"; + } + + private _convertMessageType(messageType: string): string { + switch (messageType) { + case "human": + return "user"; + case "ai": + return "assistant"; + case "system": + return "system"; + case "tool": + return "tool"; + case "function": + return "function"; + case "generic": + default: + return "user"; + } + } + + async _generate( + messages: BaseMessage[], + options: this["ParsedCallOptions"], + runManager?: CallbackManagerForLLMRun + ): Promise { + const chatMessages = messages.map((m) => ({ + role: this._convertMessageType(m._getType()), + content: m.content as string, + })); + + const response = await this.provider.sendChatMessage(chatMessages, this.modelName); + const content = response.choices?.[0]?.message?.content || ""; + + const generation: ChatGeneration = { + text: content, + message: new AIMessage(content), + }; + + return { + generations: [generation], + llmOutput: {}, // add more details here if needed + }; + } + + /** + * A simple approximation: ~4 chars per token for English text + * This matches the fallback behavior in ChatModelManager.countTokens + */ + async getNumTokens(content: MessageContent): Promise { + const text = typeof content === "string" ? content : JSON.stringify(content); + if (!text) return 0; + return Math.ceil(text.length / 4); + } +} + +export class ChatGitHubCopilot { + private provider: GitHubCopilotProvider; + constructor(config: any) { + this.provider = new GitHubCopilotProvider(); + } + async send(messages: { role: string; content: string }[], model = "gpt-4") { + return this.provider.sendChatMessage(messages, model); + } + getAuthState() { + return this.provider.getAuthState(); + } + async startAuth() { + return this.provider.startDeviceCodeFlow(); + } + async pollForAccessToken() { + return this.provider.pollForAccessToken(); + } + async fetchCopilotToken() { + return this.provider.fetchCopilotToken(); + } + resetAuth() { + this.provider.resetAuth(); + } +} From ba511030b02936eca3e050b3695c15ce3c1168b7 Mon Sep 17 00:00:00 2001 From: 137 <113233555+caezium@users.noreply.github.com> Date: Wed, 25 Jun 2025 17:08:09 +0800 Subject: [PATCH 4/5] fix incorrect messasge casting for github copilot model --- src/LLMProviders/githubCopilotChatModel.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/LLMProviders/githubCopilotChatModel.ts b/src/LLMProviders/githubCopilotChatModel.ts index 2db98e4fb..536dead7c 100644 --- a/src/LLMProviders/githubCopilotChatModel.ts +++ b/src/LLMProviders/githubCopilotChatModel.ts @@ -6,6 +6,7 @@ import { AIMessage, type BaseMessage, type MessageContent } from "@langchain/cor import { type ChatResult, ChatGeneration } from "@langchain/core/outputs"; import { type CallbackManagerForLLMRun } from "@langchain/core/callbacks/manager"; import { GitHubCopilotProvider } from "./githubCopilotProvider"; +import { extractTextFromChunk } from "@/utils"; export interface CopilotChatModelParams extends BaseChatModelParams { provider: GitHubCopilotProvider; @@ -53,7 +54,7 @@ export class CopilotChatModel extends BaseChatModel { ): Promise { const chatMessages = messages.map((m) => ({ role: this._convertMessageType(m._getType()), - content: m.content as string, + content: extractTextFromChunk(m.content), })); const response = await this.provider.sendChatMessage(chatMessages, this.modelName); From 4afc2fc3be6664c48d6896e167a65e0b7529de89 Mon Sep 17 00:00:00 2001 From: 137 <113233555+caezium@users.noreply.github.com> Date: Fri, 27 Jun 2025 06:44:36 +0800 Subject: [PATCH 5/5] fix refactor issues --- src/LLMProviders/chatModelManager.ts | 44 +++++++++++++--------- src/LLMProviders/githubCopilotChatModel.ts | 25 ------------ 2 files changed, 27 insertions(+), 42 deletions(-) diff --git a/src/LLMProviders/chatModelManager.ts b/src/LLMProviders/chatModelManager.ts index 6bfa9f39d..36018a589 100644 --- a/src/LLMProviders/chatModelManager.ts +++ b/src/LLMProviders/chatModelManager.ts @@ -22,13 +22,13 @@ import { ChatOpenAI } from "@langchain/openai"; import { ChatXAI } from "@langchain/xai"; import { Notice } from "obsidian"; import { GitHubCopilotProvider } from "./githubCopilotProvider"; -import { ChatGitHubCopilot, CopilotChatModel } from "./githubCopilotChatModel"; +import { CopilotChatModel } from "./githubCopilotChatModel"; type ChatConstructorType = { new (config: any): any; }; -const CHAT_PROVIDER_CONSTRUCTORS = { +const CHAT_PROVIDER_CONSTRUCTORS: Partial> = { [ChatModelProviders.OPENAI]: ChatOpenAI, [ChatModelProviders.AZURE_OPENAI]: ChatOpenAI, [ChatModelProviders.ANTHROPIC]: ChatAnthropic, @@ -43,11 +43,8 @@ const CHAT_PROVIDER_CONSTRUCTORS = { [ChatModelProviders.COPILOT_PLUS]: ChatOpenAI, [ChatModelProviders.MISTRAL]: ChatMistralAI, [ChatModelProviders.DEEPSEEK]: ChatDeepSeek, - [ChatModelProviders.GITHUB_COPILOT]: ChatGitHubCopilot, // Register GitHub Copilot } as const; -type ChatProviderConstructMap = typeof CHAT_PROVIDER_CONSTRUCTORS; - export default class ChatModelManager { private static instance: ChatModelManager; private static chatModel: BaseChatModel | null; @@ -60,7 +57,7 @@ export default class ChatModelManager { } >; - private readonly providerApiKeyMap: Record string> = { + private readonly providerApiKeyMap: Partial string>> = { [ChatModelProviders.OPENAI]: () => getSettings().openAIApiKey, [ChatModelProviders.GOOGLE]: () => getSettings().googleApiKey, [ChatModelProviders.AZURE_OPENAI]: () => getSettings().azureOpenAIApiKey, @@ -75,7 +72,6 @@ export default class ChatModelManager { [ChatModelProviders.COPILOT_PLUS]: () => getSettings().plusLicenseKey, [ChatModelProviders.MISTRAL]: () => getSettings().mistralApiKey, [ChatModelProviders.DEEPSEEK]: () => getSettings().deepseekApiKey, - [ChatModelProviders.GITHUB_COPILOT]: () => "", // Placeholder for GitHub Copilot } as const; private constructor() { @@ -121,9 +117,7 @@ export default class ChatModelManager { (baseConfig as any).temperature = customModel.temperature ?? settings.temperature; } - const providerConfig: { - [K in keyof ChatProviderConstructMap]: ConstructorParameters[0]; - } = { + const providerConfig = { [ChatModelProviders.OPENAI]: { modelName: modelName, openAIApiKey: await getDecryptedKey(customModel.apiKey || settings.openAIApiKey), @@ -260,8 +254,7 @@ export default class ChatModelManager { fetch: customModel.enableCors ? safeFetch : undefined, }, }, - [ChatModelProviders.GITHUB_COPILOT]: {}, // Placeholder config for GitHub Copilot - }; + } as any; const selectedProviderConfig = providerConfig[customModel.provider as keyof typeof providerConfig] || {}; @@ -326,7 +319,7 @@ export default class ChatModelManager { const constructor = this.getProviderConstructor(model); const getDefaultApiKey = this.providerApiKeyMap[model.provider as ChatModelProviders]; - const apiKey = model.apiKey || getDefaultApiKey(); + const apiKey = model.apiKey || (getDefaultApiKey ? getDefaultApiKey() : ""); const modelKey = getModelKeyFromModel(model); modelMap[modelKey] = { hasApiKey: Boolean(model.apiKey || apiKey), @@ -338,11 +331,9 @@ export default class ChatModelManager { } getProviderConstructor(model: CustomModel): ChatConstructorType { - const constructor: ChatConstructorType = - CHAT_PROVIDER_CONSTRUCTORS[model.provider as ChatModelProviders]; + const constructor = CHAT_PROVIDER_CONSTRUCTORS[model.provider as ChatModelProviders]; if (!constructor) { - console.warn(`Unknown provider: ${model.provider} for model: ${model.name}`); - throw new Error(`Unknown provider: ${model.provider} for model: ${model.name}`); + throw new Error(`No chat model constructor registered for provider: ${model.provider}`); } return constructor; } @@ -366,11 +357,30 @@ export default class ChatModelManager { } async createModelInstance(model: CustomModel): Promise { + // Validate model existence + if (!model) { + throw new Error("No model provided to createModelInstance."); + } + + // Special handling for GitHub Copilot if (model.provider === ChatModelProviders.GITHUB_COPILOT) { const provider = new GitHubCopilotProvider(); return new CopilotChatModel({ provider, modelName: model.name }); } + // Validate model is enabled and has API key if required + const modelKey = getModelKeyFromModel(model); + const selectedModel = ChatModelManager.modelMap?.[modelKey]; + if (!selectedModel) { + throw new Error(`Model '${model.name}' is not enabled or not found in the model map.`); + } + if (!selectedModel.hasApiKey) { + throw new Error( + `API key is missing for model '${model.name}'. Please check your API key settings.` + ); + } + + // Only now get the constructor const AIConstructor = this.getProviderConstructor(model); const config = await this.getModelConfig(model); diff --git a/src/LLMProviders/githubCopilotChatModel.ts b/src/LLMProviders/githubCopilotChatModel.ts index 536dead7c..51be2959f 100644 --- a/src/LLMProviders/githubCopilotChatModel.ts +++ b/src/LLMProviders/githubCopilotChatModel.ts @@ -81,28 +81,3 @@ export class CopilotChatModel extends BaseChatModel { return Math.ceil(text.length / 4); } } - -export class ChatGitHubCopilot { - private provider: GitHubCopilotProvider; - constructor(config: any) { - this.provider = new GitHubCopilotProvider(); - } - async send(messages: { role: string; content: string }[], model = "gpt-4") { - return this.provider.sendChatMessage(messages, model); - } - getAuthState() { - return this.provider.getAuthState(); - } - async startAuth() { - return this.provider.startDeviceCodeFlow(); - } - async pollForAccessToken() { - return this.provider.pollForAccessToken(); - } - async fetchCopilotToken() { - return this.provider.fetchCopilotToken(); - } - resetAuth() { - this.provider.resetAuth(); - } -}