Skip to content

Commit f1ee68c

Browse files
committed
feat: add support for Github Copilot as a provider
1 parent 225bbd2 commit f1ee68c

File tree

7 files changed

+409
-25
lines changed

7 files changed

+409
-25
lines changed

src/LLMProviders/chatModelManager.ts

Lines changed: 91 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { HarmBlockThreshold, HarmCategory } from "@google/generative-ai";
1313
import { ChatAnthropic } from "@langchain/anthropic";
1414
import { ChatCohere } from "@langchain/cohere";
1515
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
16+
import { AIMessage } from "@langchain/core/messages";
17+
import { Runnable } from "@langchain/core/runnables";
1618
import { ChatDeepSeek } from "@langchain/deepseek";
1719
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
1820
import { ChatGroq } from "@langchain/groq";
@@ -21,11 +23,63 @@ import { ChatOllama } from "@langchain/ollama";
2123
import { ChatOpenAI } from "@langchain/openai";
2224
import { ChatXAI } from "@langchain/xai";
2325
import { Notice } from "obsidian";
26+
import { GitHubCopilotProvider } from "./githubCopilotProvider";
27+
import { ChatPromptValue } from "@langchain/core/prompt_values";
28+
29+
class CopilotRunnable extends Runnable {
30+
lc_serializable = false;
31+
lc_namespace = ["langchain", "chat_models", "copilot"];
32+
private provider: GitHubCopilotProvider;
33+
private modelName: string;
34+
35+
constructor(provider: GitHubCopilotProvider, modelName: string) {
36+
super();
37+
this.provider = provider;
38+
this.modelName = modelName;
39+
}
40+
41+
async invoke(input: ChatPromptValue, options?: any): Promise<any> {
42+
const messages = input.toChatMessages().map((m) => ({
43+
role: m._getType() === "human" ? "user" : "assistant",
44+
content: m.content as string,
45+
}));
46+
const response = await this.provider.sendChatMessage(messages, this.modelName);
47+
const content = response.choices?.[0]?.message?.content || "";
48+
return new AIMessage(content);
49+
}
50+
}
2451

2552
type ChatConstructorType = {
2653
new (config: any): any;
2754
};
2855

56+
// Placeholder for GitHub Copilot chat provider
57+
class ChatGitHubCopilot {
58+
private provider: GitHubCopilotProvider;
59+
constructor(config: any) {
60+
this.provider = new GitHubCopilotProvider();
61+
// TODO: Use config for persistent storage, UI callbacks, etc.
62+
}
63+
async send(messages: { role: string; content: string }[], model = "gpt-4") {
64+
return this.provider.sendChatMessage(messages, model);
65+
}
66+
getAuthState() {
67+
return this.provider.getAuthState();
68+
}
69+
async startAuth() {
70+
return this.provider.startDeviceCodeFlow();
71+
}
72+
async pollForAccessToken() {
73+
return this.provider.pollForAccessToken();
74+
}
75+
async fetchCopilotToken() {
76+
return this.provider.fetchCopilotToken();
77+
}
78+
resetAuth() {
79+
this.provider.resetAuth();
80+
}
81+
}
82+
2983
const CHAT_PROVIDER_CONSTRUCTORS = {
3084
[ChatModelProviders.OPENAI]: ChatOpenAI,
3185
[ChatModelProviders.AZURE_OPENAI]: ChatOpenAI,
@@ -41,6 +95,7 @@ const CHAT_PROVIDER_CONSTRUCTORS = {
4195
[ChatModelProviders.COPILOT_PLUS]: ChatOpenAI,
4296
[ChatModelProviders.MISTRAL]: ChatMistralAI,
4397
[ChatModelProviders.DEEPSEEK]: ChatDeepSeek,
98+
[ChatModelProviders.GITHUB_COPILOT]: ChatGitHubCopilot, // Register GitHub Copilot
4499
} as const;
45100

46101
type ChatProviderConstructMap = typeof CHAT_PROVIDER_CONSTRUCTORS;
@@ -72,6 +127,7 @@ export default class ChatModelManager {
72127
[ChatModelProviders.COPILOT_PLUS]: () => getSettings().plusLicenseKey,
73128
[ChatModelProviders.MISTRAL]: () => getSettings().mistralApiKey,
74129
[ChatModelProviders.DEEPSEEK]: () => getSettings().deepseekApiKey,
130+
[ChatModelProviders.GITHUB_COPILOT]: () => "", // Placeholder for GitHub Copilot
75131
} as const;
76132

77133
private constructor() {
@@ -97,10 +153,16 @@ export default class ChatModelManager {
97153
const isThinkingEnabled =
98154
modelName.startsWith("claude-3-7-sonnet") || modelName.startsWith("claude-sonnet-4");
99155

156+
// For GitHub Copilot, streaming is not supported
157+
const streaming =
158+
customModel.provider === ChatModelProviders.GITHUB_COPILOT
159+
? false
160+
: (customModel.stream ?? true);
161+
100162
// Base config without temperature when thinking is enabled
101163
const baseConfig: Omit<ModelConfig, "maxTokens" | "maxCompletionTokens" | "temperature"> = {
102164
modelName: modelName,
103-
streaming: customModel.stream ?? true,
165+
streaming,
104166
maxRetries: 3,
105167
maxConcurrency: 3,
106168
enableCors: customModel.enableCors,
@@ -250,6 +312,7 @@ export default class ChatModelManager {
250312
fetch: customModel.enableCors ? safeFetch : undefined,
251313
},
252314
},
315+
[ChatModelProviders.GITHUB_COPILOT]: {}, // Placeholder config for GitHub Copilot
253316
};
254317

255318
const selectedProviderConfig =
@@ -344,35 +407,28 @@ export default class ChatModelManager {
344407
}
345408

346409
async setChatModel(model: CustomModel): Promise<void> {
347-
const modelKey = getModelKeyFromModel(model);
348410
try {
349-
const modelInstance = await this.createModelInstance(model);
350-
ChatModelManager.chatModel = modelInstance;
351-
} catch (error) {
352-
logError(error);
353-
new Notice(`Error creating model: ${modelKey}`);
411+
ChatModelManager.chatModel = await this.createModelInstance(model);
412+
logInfo(`Chat model set to ${model.name}`);
413+
} catch (e) {
414+
logError("Failed to set chat model:", e);
415+
new Notice(`Failed to set chat model: ${e.message}`);
416+
ChatModelManager.chatModel = null;
354417
}
355418
}
356419

357420
async createModelInstance(model: CustomModel): Promise<BaseChatModel> {
358-
// Create and return the appropriate model
359-
const modelKey = getModelKeyFromModel(model);
360-
const selectedModel = ChatModelManager.modelMap[modelKey];
361-
if (!selectedModel) {
362-
throw new Error(`No model found for: ${modelKey}`);
363-
}
364-
if (!selectedModel.hasApiKey) {
365-
const errorMessage = `API key is not provided for the model: ${modelKey}.`;
366-
new Notice(errorMessage);
367-
throw new Error(errorMessage);
421+
if (model.provider === ChatModelProviders.GITHUB_COPILOT) {
422+
const provider = new GitHubCopilotProvider();
423+
const copilotRunnable = new CopilotRunnable(provider, model.name);
424+
// The type assertion is a bit of a hack, but it makes it work with the existing structure
425+
return copilotRunnable as unknown as BaseChatModel;
368426
}
369427

370-
const modelConfig = await this.getModelConfig(model);
428+
const AIConstructor = this.getProviderConstructor(model);
429+
const config = await this.getModelConfig(model);
371430

372-
const newModelInstance = new selectedModel.AIConstructor({
373-
...modelConfig,
374-
});
375-
return newModelInstance;
431+
return new AIConstructor(config);
376432
}
377433

378434
validateChatModel(chatModel: BaseChatModel): boolean {
@@ -427,6 +483,19 @@ export default class ChatModelManager {
427483
}
428484

429485
async ping(model: CustomModel): Promise<boolean> {
486+
if (model.provider === ChatModelProviders.GITHUB_COPILOT) {
487+
const provider = new GitHubCopilotProvider();
488+
const state = provider.getAuthState();
489+
if (state.status === "authenticated") {
490+
new Notice("GitHub Copilot is authenticated.");
491+
return true;
492+
} else {
493+
new Notice(
494+
"GitHub Copilot is not authenticated. Please set it up in the 'Basic' settings tab."
495+
);
496+
return false;
497+
}
498+
}
430499
const tryPing = async (enableCors: boolean) => {
431500
const modelToTest = { ...model, enableCors };
432501
const modelConfig = await this.getModelConfig(modelToTest);
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Based on the flow used in Pierrad/obsidian-github-copilot
2+
// Handles device code flow, token exchange, and chat requests
3+
4+
import { getSettings, updateSetting } from "@/settings/model";
5+
import { requestUrl, Notice } from "obsidian";
6+
7+
const CLIENT_ID = "Iv1.b507a08c87ecfe98"; // Copilot VSCode client ID
8+
const DEVICE_CODE_URL = "https://github.com/login/device/code";
9+
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
10+
const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
11+
const CHAT_COMPLETIONS_URL = "https://api.githubcopilot.com/chat/completions";
12+
13+
export interface CopilotAuthState {
14+
deviceCode?: string;
15+
userCode?: string;
16+
verificationUri?: string;
17+
expiresIn?: number;
18+
interval?: number;
19+
accessToken?: string;
20+
copilotToken?: string;
21+
copilotTokenExpiresAt?: number;
22+
status: "idle" | "pending" | "authenticated" | "error";
23+
error?: string;
24+
}
25+
26+
export class GitHubCopilotProvider {
27+
private authState: CopilotAuthState = { status: "idle" };
28+
29+
constructor() {
30+
// Load persisted tokens from settings
31+
const settings = getSettings();
32+
if (settings.copilotAccessToken && settings.copilotToken) {
33+
this.authState.accessToken = settings.copilotAccessToken;
34+
this.authState.copilotToken = settings.copilotToken;
35+
this.authState.copilotTokenExpiresAt = settings.copilotTokenExpiresAt;
36+
if (settings.copilotTokenExpiresAt && settings.copilotTokenExpiresAt > Date.now()) {
37+
this.authState.status = "authenticated";
38+
}
39+
}
40+
}
41+
42+
getAuthState() {
43+
return this.authState;
44+
}
45+
46+
// Step 1: Start device code flow
47+
async startDeviceCodeFlow() {
48+
this.authState.status = "pending";
49+
try {
50+
const res = await requestUrl({
51+
url: DEVICE_CODE_URL,
52+
method: "POST",
53+
headers: { "Content-Type": "application/json", Accept: "application/json" },
54+
body: JSON.stringify({ client_id: CLIENT_ID, scope: "read:user" }),
55+
});
56+
if (res.status !== 200) throw new Error("Failed to get device code");
57+
const data = res.json;
58+
this.authState.deviceCode = data.device_code;
59+
this.authState.userCode = data.user_code;
60+
this.authState.verificationUri = data.verification_uri || data.verification_uri_complete;
61+
this.authState.expiresIn = data.expires_in;
62+
this.authState.interval = data.interval;
63+
this.authState.status = "pending";
64+
return {
65+
userCode: data.user_code,
66+
verificationUri: data.verification_uri || data.verification_uri_complete,
67+
};
68+
} catch (e: any) {
69+
this.authState.status = "error";
70+
this.authState.error = e.message;
71+
throw e;
72+
}
73+
}
74+
75+
// Step 2: Poll for access token
76+
async pollForAccessToken() {
77+
if (!this.authState.deviceCode) throw new Error("No device code");
78+
79+
new Notice("Waiting for you to authorize in the browser...", 15000);
80+
81+
const poll = async () => {
82+
const res = await requestUrl({
83+
url: ACCESS_TOKEN_URL,
84+
method: "POST",
85+
headers: { "Content-Type": "application/json", Accept: "application/json" },
86+
body: JSON.stringify({
87+
client_id: CLIENT_ID,
88+
device_code: this.authState.deviceCode,
89+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
90+
}),
91+
});
92+
const data = res.json;
93+
if (data.error === "authorization_pending") return null;
94+
if (data.error) throw new Error(data.error_description || data.error);
95+
return data.access_token;
96+
};
97+
const interval = this.authState.interval || 5;
98+
const expiresAt = Date.now() + (this.authState.expiresIn || 900) * 1000;
99+
while (Date.now() < expiresAt) {
100+
const token = await poll();
101+
if (token) {
102+
this.authState.accessToken = token;
103+
// Persist access token
104+
updateSetting("copilotAccessToken", token);
105+
return token;
106+
}
107+
await new Promise((resolve) => setTimeout(resolve, interval * 1000));
108+
}
109+
throw new Error("Device code expired");
110+
}
111+
112+
// Step 3: Exchange for Copilot token
113+
async fetchCopilotToken() {
114+
if (!this.authState.accessToken) throw new Error("No access token");
115+
const res = await requestUrl({
116+
url: COPILOT_TOKEN_URL,
117+
method: "GET",
118+
headers: { Authorization: `Bearer ${this.authState.accessToken}` },
119+
});
120+
if (res.status !== 200) throw new Error("Failed to get Copilot token");
121+
const data = res.json;
122+
this.authState.copilotToken = data.token;
123+
this.authState.copilotTokenExpiresAt =
124+
Date.now() + (data.expires_at ? data.expires_at * 1000 : 3600 * 1000);
125+
this.authState.status = "authenticated";
126+
// Persist Copilot token and expiration
127+
updateSetting("copilotToken", data.token);
128+
updateSetting("copilotTokenExpiresAt", this.authState.copilotTokenExpiresAt);
129+
return data.token;
130+
}
131+
132+
// Step 4: Send chat message
133+
async sendChatMessage(messages: { role: string; content: string }[], model = "gpt-4") {
134+
if (!this.authState.copilotToken) throw new Error("Not authenticated with Copilot");
135+
const res = await requestUrl({
136+
url: CHAT_COMPLETIONS_URL,
137+
method: "POST",
138+
headers: {
139+
"Content-Type": "application/json",
140+
Authorization: `Bearer ${this.authState.copilotToken}`,
141+
"User-Agent": "vscode/1.80.1",
142+
"Editor-Version": "vscode/1.80.1",
143+
"OpenAI-Intent": "conversation-panel",
144+
},
145+
body: JSON.stringify({
146+
model,
147+
messages,
148+
stream: false,
149+
}),
150+
});
151+
if (res.status !== 200) throw new Error("Copilot chat request failed");
152+
const data = res.json;
153+
return data;
154+
}
155+
156+
// Utility: Reset authentication state
157+
resetAuth() {
158+
this.authState = { status: "idle" };
159+
// Clear persisted tokens
160+
updateSetting("copilotAccessToken", "");
161+
updateSetting("copilotToken", "");
162+
updateSetting("copilotTokenExpiresAt", 0);
163+
}
164+
}

src/constants.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export enum ChatModelProviders {
148148
COPILOT_PLUS = "copilot-plus",
149149
MISTRAL = "mistralai",
150150
DEEPSEEK = "deepseek",
151+
GITHUB_COPILOT = "github-copilot",
151152
}
152153

153154
export enum ModelCapability {
@@ -507,6 +508,12 @@ export const ProviderInfo: Record<Provider, ProviderMetadata> = {
507508
keyManagementURL: "",
508509
listModelURL: "",
509510
},
511+
[ChatModelProviders.GITHUB_COPILOT]: {
512+
label: "GitHub Copilot",
513+
host: "https://api.githubcopilot.com",
514+
keyManagementURL: "",
515+
listModelURL: "",
516+
},
510517
[EmbeddingModelProviders.COPILOT_PLUS_JINA]: {
511518
label: "Copilot Plus",
512519
host: "https://api.brevilabs.com/v1",
@@ -528,6 +535,7 @@ export const ProviderSettingsKeyMap: Record<SettingKeyProviders, keyof CopilotSe
528535
"copilot-plus": "plusLicenseKey",
529536
mistralai: "mistralApiKey",
530537
deepseek: "deepseekApiKey",
538+
"github-copilot": "copilotToken",
531539
};
532540

533541
export enum VAULT_VECTOR_STORE_STRATEGY {
@@ -666,6 +674,10 @@ export const DEFAULT_SETTINGS: CopilotSettings = {
666674
passMarkdownImages: true,
667675
enableCustomPromptTemplating: true,
668676
suggestedDefaultCommands: false,
677+
// GitHub Copilot provider tokens (optional, for persistence)
678+
copilotAccessToken: "",
679+
copilotToken: "",
680+
copilotTokenExpiresAt: 0,
669681
};
670682

671683
export const EVENT_NAMES = {

src/settings/model.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ export interface CopilotSettings {
112112
enableCustomPromptTemplating: boolean;
113113
/** Whether we have suggested built-in default commands to the user once. */
114114
suggestedDefaultCommands: boolean;
115+
copilotAccessToken?: string;
116+
copilotToken?: string;
117+
copilotTokenExpiresAt?: number;
115118
}
116119

117120
export const settingsStore = createStore();

0 commit comments

Comments
 (0)