-
Notifications
You must be signed in to change notification settings - Fork 0
Add GitHub Copilot provider support #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,7 @@ import { | |
| } from "../session.js"; | ||
| import { applyPatchToolInstructions } from "./apply-patch.js"; | ||
| import { handleExecCommand } from "./handle-exec-command.js"; | ||
| import { GithubCopilotClient } from "../openai-client.js"; | ||
| import { HttpsProxyAgent } from "https-proxy-agent"; | ||
| import { spawnSync } from "node:child_process"; | ||
| import { randomUUID } from "node:crypto"; | ||
|
|
@@ -350,6 +351,24 @@ export class AgentLoop { | |
| }); | ||
| } | ||
|
|
||
| if (this.provider.toLowerCase() === "githubcopilot") { | ||
|
||
| this.oai = new GithubCopilotClient({ | ||
| ...(apiKey ? { apiKey } : {}), | ||
| baseURL, | ||
| defaultHeaders: { | ||
| originator: ORIGIN, | ||
| version: CLI_VERSION, | ||
| session_id: this.sessionId, | ||
| ...(OPENAI_ORGANIZATION | ||
| ? { "OpenAI-Organization": OPENAI_ORGANIZATION } | ||
| : {}), | ||
| ...(OPENAI_PROJECT ? { "OpenAI-Project": OPENAI_PROJECT } : {}), | ||
| }, | ||
| httpAgent: PROXY_URL ? new HttpsProxyAgent(PROXY_URL) : undefined, | ||
| ...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}), | ||
| }); | ||
| } | ||
|
|
||
| setSessionId(this.sessionId); | ||
| setCurrentModel(this.model); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,6 @@ | ||
| import type { AppConfig } from "./config.js"; | ||
| import type { ClientOptions } from "openai"; | ||
| import type * as Core from "openai/core"; | ||
|
|
||
| import { | ||
| getBaseUrl, | ||
|
|
@@ -9,6 +11,7 @@ import { | |
| OPENAI_PROJECT, | ||
| } from "./config.js"; | ||
| import OpenAI, { AzureOpenAI } from "openai"; | ||
| import * as Errors from "openai/error"; | ||
|
|
||
| type OpenAIClientConfig = { | ||
| provider: string; | ||
|
|
@@ -42,10 +45,166 @@ export function createOpenAIClient( | |
| }); | ||
| } | ||
|
|
||
| if (config.provider?.toLowerCase() === "githubcopilot") { | ||
| return new GithubCopilotClient({ | ||
| apiKey: getApiKey(config.provider), | ||
| baseURL: getBaseUrl(config.provider), | ||
| timeout: OPENAI_TIMEOUT_MS, | ||
| defaultHeaders: headers, | ||
| }); | ||
| } | ||
|
|
||
| return new OpenAI({ | ||
| apiKey: getApiKey(config.provider), | ||
| baseURL: getBaseUrl(config.provider), | ||
| timeout: OPENAI_TIMEOUT_MS, | ||
| defaultHeaders: headers, | ||
| }); | ||
| } | ||
|
|
||
| export class GithubCopilotClient extends OpenAI { | ||
| private copilotToken: string | null = null; | ||
| private copilotTokenExpiration = new Date(); | ||
| private githubAPIKey: string; | ||
|
|
||
| constructor(opts: ClientOptions = {}) { | ||
| super(opts); | ||
| if (!opts.apiKey) { | ||
| throw new Errors.OpenAIError("missing github copilot token"); | ||
| } | ||
| this.githubAPIKey = opts.apiKey; | ||
| } | ||
|
|
||
| private async _getGithubCopilotToken(): Promise<string | undefined> { | ||
| if ( | ||
| this.copilotToken && | ||
| this.copilotTokenExpiration.getTime() > Date.now() | ||
| ) { | ||
| return this.copilotToken; | ||
| } | ||
| const resp = await fetch( | ||
| "https://api.github.com/copilot_internal/v2/token", | ||
| { | ||
| method: "GET", | ||
| headers: GithubCopilotClient._mergeGithubHeaders({ | ||
| "Authorization": `bearer ${this.githubAPIKey}`, | ||
| "Accept": "application/json", | ||
| "Content-Type": "application/json", | ||
| }), | ||
| }, | ||
| ); | ||
| if (!resp.ok) { | ||
| const text = await resp.text(); | ||
| throw new Error("unable to get github copilot auth token: " + text); | ||
| } | ||
| const text = await resp.text(); | ||
| const { token, refresh_in } = JSON.parse(text); | ||
| if (typeof token !== "string" || typeof refresh_in !== "number") { | ||
| throw new Errors.OpenAIError( | ||
| `unexpected response from copilot auth: ${text}`, | ||
| ); | ||
| } | ||
| this.copilotToken = token; | ||
| this.copilotTokenExpiration = new Date(Date.now() + refresh_in * 1000); | ||
| return token; | ||
| } | ||
|
|
||
| protected override authHeaders( | ||
| _opts: Core.FinalRequestOptions, | ||
| ): Core.Headers { | ||
| return {}; | ||
| } | ||
|
|
||
| protected override async prepareOptions( | ||
| opts: Core.FinalRequestOptions<unknown>, | ||
| ): Promise<void> { | ||
| const token = await this._getGithubCopilotToken(); | ||
| opts.headers ??= {}; | ||
| if (token) { | ||
| opts.headers["Authorization"] = `Bearer ${token}`; | ||
| opts.headers = GithubCopilotClient._mergeGithubHeaders(opts.headers); | ||
| } else { | ||
| throw new Errors.OpenAIError("Unable to handle auth"); | ||
| } | ||
| return super.prepareOptions(opts); | ||
| } | ||
|
|
||
| static async getLoginURL(): Promise<{ | ||
| device_code: string; | ||
| user_code: string; | ||
| verification_uri: string; | ||
| }> { | ||
| const resp = await fetch("https://github.com/login/device/code", { | ||
| method: "POST", | ||
| headers: this._mergeGithubHeaders({ | ||
| "Content-Type": "application/json", | ||
| "accept": "application/json", | ||
| }), | ||
| body: JSON.stringify({ | ||
| client_id: "Iv1.b507a08c87ecfe98", | ||
| scope: "read:user", | ||
| }), | ||
| }); | ||
| if (!resp.ok) { | ||
| const text = await resp.text(); | ||
| throw new Errors.OpenAIError("Unable to get login device code: " + text); | ||
| } | ||
| return resp.json(); | ||
| } | ||
|
|
||
| static async pollForAccessToken(deviceCode: string): Promise<string> { | ||
| /*eslint no-await-in-loop: "off"*/ | ||
| const MAX_ATTEMPTS = 36; | ||
| let lastErr: unknown = null; | ||
| for (let i = 0; i < MAX_ATTEMPTS; ++i) { | ||
| try { | ||
| const resp = await fetch( | ||
| "https://github.com/login/oauth/access_token", | ||
| { | ||
| method: "POST", | ||
| headers: this._mergeGithubHeaders({ | ||
| "Content-Type": "application/json", | ||
| "accept": "application/json", | ||
| }), | ||
| body: JSON.stringify({ | ||
| client_id: "Iv1.b507a08c87ecfe98", | ||
| device_code: deviceCode, | ||
| grant_type: "urn:ietf:params:oauth:grant-type:device_code", | ||
| }), | ||
| }, | ||
| ); | ||
| if (!resp.ok) { | ||
| continue; | ||
| } | ||
| const info = await resp.json(); | ||
| if (info.access_token) { | ||
| return info.access_token as string; | ||
| } else if (info.error === "authorization_pending") { | ||
| lastErr = null; | ||
| } else { | ||
| throw new Errors.OpenAIError( | ||
| "unexpected response when polling for access token: " + | ||
| JSON.stringify(info), | ||
| ); | ||
| } | ||
| } catch (err) { | ||
| lastErr = err; | ||
| } | ||
| await new Promise((resolve) => setTimeout(resolve, 5_000)); | ||
| } | ||
| throw new Errors.OpenAIError( | ||
| "timed out waiting for access token", | ||
| lastErr != null ? { cause: lastErr } : {}, | ||
| ); | ||
| } | ||
|
|
||
| private static _mergeGithubHeaders< | ||
| T extends Core.Headers | Record<string, string>, | ||
| >(headers: T): T { | ||
| const copy = { ...headers } as Record<string, string> & T; | ||
| copy["User-Agent"] = "GithubCopilot/1.155.0"; | ||
| copy["editor-version"] = "vscode/1.85.1"; | ||
|
||
| copy["editor-plugin-version"] = "copilot/1.155.0"; | ||
| return copy as T; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This block triggers the Copilot login flow unconditionally when no key is present, even without the '--login' flag; consider guarding it under 'cli.flags.login' to avoid unexpected prompts.