diff --git a/codex-cli/src/cli.tsx b/codex-cli/src/cli.tsx index 8791a43e1ca..9f5b69e0257 100644 --- a/codex-cli/src/cli.tsx +++ b/codex-cli/src/cli.tsx @@ -39,8 +39,8 @@ import { } from "./utils/config"; import { getApiKey as fetchApiKey, + getGithubCopilotApiKey as fetchGithubCopilotApiKey, maybeRedeemCredits, - fetchGithubCopilotApiKey, } from "./utils/get-api-key"; import { createInputItem } from "./utils/input-utils"; import { initLogger } from "./utils/logger/log"; @@ -323,12 +323,38 @@ try { if (data.OPENAI_API_KEY && !expired) { apiKey = data.OPENAI_API_KEY; } + if ( + data.GITHUBCOPILOT_API_KEY && + provider.toLowerCase() === "githubcopilot" + ) { + apiKey = data.GITHUBCOPILOT_API_KEY; + } } } catch { // ignore errors } -if (cli.flags.login) { +if (provider.toLowerCase() === "githubcopilot" && !apiKey) { + apiKey = await fetchGithubCopilotApiKey(); + try { + const home = os.homedir(); + const authDir = path.join(home, ".codex"); + const authFile = path.join(authDir, "auth.json"); + fs.writeFileSync( + authFile, + JSON.stringify( + { + GITHUBCOPILOT_API_KEY: apiKey, + }, + null, + 2, + ), + "utf-8", + ); + } catch { + /* ignore */ + } +} else if (cli.flags.login) { if (provider.toLowerCase() === "githubcopilot") { apiKey = await fetchGithubCopilotApiKey(); } else { @@ -353,7 +379,7 @@ if (cli.flags.login) { } } // Ensure the API key is available as an environment variable for legacy code -process.env["OPENAI_API_KEY"] = apiKey; +process.env[`${provider.toUpperCase()}_API_KEY`] = apiKey; if (cli.flags.free) { // eslint-disable-next-line no-console diff --git a/codex-cli/src/utils/agent/agent-loop.ts b/codex-cli/src/utils/agent/agent-loop.ts index cc57239b40f..137887f7aea 100644 --- a/codex-cli/src/utils/agent/agent-loop.ts +++ b/codex-cli/src/utils/agent/agent-loop.ts @@ -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); diff --git a/codex-cli/src/utils/get-api-key.tsx b/codex-cli/src/utils/get-api-key.tsx index 7bb9b2f0843..e102aef36dd 100644 --- a/codex-cli/src/utils/get-api-key.tsx +++ b/codex-cli/src/utils/get-api-key.tsx @@ -2,10 +2,12 @@ import type { Choice } from "./get-api-key-components"; import type { Request, Response } from "express"; import { ApiKeyPrompt, WaitingForAuth } from "./get-api-key-components"; +import { GithubCopilotClient } from "./openai-client.js"; +import Spinner from "../components/vendor/ink-spinner.js"; import chalk from "chalk"; import express from "express"; import fs from "fs/promises"; -import { render } from "ink"; +import { Box, Text, render } from "ink"; import crypto from "node:crypto"; import { URL } from "node:url"; import open from "open"; @@ -763,26 +765,29 @@ export async function getApiKey( } } -export { maybeRedeemCredits }; - -export async function fetchGithubCopilotApiKey(): Promise { - if (process.env["GITHUB_COPILOT_TOKEN"]) { - return process.env["GITHUB_COPILOT_TOKEN"]!; - } - - const choice = await promptUserForChoice(); - if (choice.type === "apikey") { - process.env["GITHUB_COPILOT_TOKEN"] = choice.key; - return choice.key; - } - - // Sign in via GitHub is not yet supported; instruct the user - // eslint-disable-next-line no-console - console.error( - "\n" + - "GitHub OAuth login is not yet implemented for Codex. " + - "Please generate a token manually and set it as GITHUB_COPILOT_TOKEN." + - "\n" +export async function getGithubCopilotApiKey(): Promise { + const { device_code, user_code, verification_uri } = + await GithubCopilotClient.getLoginURL(); + const spinner = render( + + + + {" "} + Please visit {verification_uri} and enter code {user_code} + + , ); - process.exit(1); + try { + const key = await GithubCopilotClient.pollForAccessToken(device_code); + spinner.clear(); + spinner.unmount(); + process.env["GITHUBCOPILOT_API_KEY"] = key; + return key; + } catch (err) { + spinner.clear(); + spinner.unmount(); + throw err; + } } + +export { maybeRedeemCredits }; diff --git a/codex-cli/src/utils/openai-client.ts b/codex-cli/src/utils/openai-client.ts index fb8117fed04..0bb850c3bca 100644 --- a/codex-cli/src/utils/openai-client.ts +++ b/codex-cli/src/utils/openai-client.ts @@ -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,6 +45,15 @@ 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), @@ -49,3 +61,150 @@ export function createOpenAIClient( 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 { + 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, + ): Promise { + 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 { + /*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, + >(headers: T): T { + const copy = { ...headers } as Record & 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; + } +} diff --git a/codex-cli/src/utils/providers.ts b/codex-cli/src/utils/providers.ts index 6ba45676d7d..88da52097ef 100644 --- a/codex-cli/src/utils/providers.ts +++ b/codex-cli/src/utils/providers.ts @@ -53,8 +53,8 @@ export const providers: Record< envKey: "ARCEEAI_API_KEY", }, githubcopilot: { - name: "GitHubCopilot", - baseURL: "https://copilot-proxy.githubusercontent.com/v1", - envKey: "GITHUB_COPILOT_TOKEN", + name: "GithubCopilot", + baseURL: "https://api.githubcopilot.com", + envKey: "GITHUBCOPILOT_API_KEY", }, };