From b973521ed8be1aa5aa0f3dd69e40ee3d2a792722 Mon Sep 17 00:00:00 2001 From: notlikejuice <158367215+notlikejuice@users.noreply.github.com> Date: Sun, 15 Jun 2025 22:58:35 +0200 Subject: [PATCH 1/2] docs: add GitHub Copilot provider info --- README.md | 9 + codex-cli/src/cli.tsx | 36 +++- codex-cli/src/utils/agent/agent-loop.ts | 19 +++ codex-cli/src/utils/agent/exec.ts | 2 + .../src/utils/agent/sandbox/interface.ts | 2 + codex-cli/src/utils/agent/sandbox/raw-exec.ts | 8 +- codex-cli/src/utils/get-api-key.tsx | 49 +++--- codex-cli/src/utils/openai-client.ts | 159 ++++++++++++++++++ codex-cli/src/utils/providers.ts | 6 +- codex-cli/tests/cancel-exec.test.ts | 2 + .../tests/invalid-command-handling.test.ts | 1 + .../tests/raw-exec-process-group.test.ts | 35 +++- 12 files changed, 296 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 24f362f77f8..045f60fe0f6 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ export OPENAI_API_KEY="your-api-key-here" > - xai > - groq > - arceeai +> - githubcopilot > - any other provider that is compatible with the OpenAI API > > If you use a provider other than OpenAI, you will need to set the API key for the provider in the config file or in the environment variable as: @@ -440,6 +441,11 @@ Below is a comprehensive example of `config.json` with multiple custom providers "name": "ArceeAI", "baseURL": "https://conductor.arcee.ai/v1", "envKey": "ARCEEAI_API_KEY" + }, + "githubcopilot": { + "name": "GithubCopilot", + "baseURL": "https://api.githubcopilot.com", + "envKey": "GITHUBCOPILOT_API_KEY" } }, "history": { @@ -474,6 +480,9 @@ export AZURE_OPENAI_API_VERSION="2025-03-01-preview" (Optional) # OpenRouter export OPENROUTER_API_KEY="your-openrouter-key-here" +# GitHub Copilot +export GITHUBCOPILOT_API_KEY="your-copilot-token-here" + # Similarly for other providers ``` diff --git a/codex-cli/src/cli.tsx b/codex-cli/src/cli.tsx index 8791a43e1ca..5cc136b7c1b 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"; @@ -117,6 +117,10 @@ const cli = meow( $ codex "Write and run a python program that prints ASCII art" $ codex -q "fix build issues" $ codex completion bash + + Supported providers: + openai (default), openrouter, azure, gemini, ollama, mistral, + deepseek, xai, groq, arceeai, githubcopilot `, { importMeta: import.meta, @@ -323,12 +327,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 +383,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/agent/exec.ts b/codex-cli/src/utils/agent/exec.ts index 1f56dde1e39..579fbb70d3a 100644 --- a/codex-cli/src/utils/agent/exec.ts +++ b/codex-cli/src/utils/agent/exec.ts @@ -120,6 +120,7 @@ export function execApplyPatch( stdout: result, stderr: "", exitCode: 0, + pid: 0, }; } catch (error: unknown) { // @ts-expect-error error might not be an object or have a message property. @@ -128,6 +129,7 @@ export function execApplyPatch( stdout: "", stderr: stderr, exitCode: 1, + pid: 0, }; } } diff --git a/codex-cli/src/utils/agent/sandbox/interface.ts b/codex-cli/src/utils/agent/sandbox/interface.ts index d0237c6e555..54cf5fa75a6 100644 --- a/codex-cli/src/utils/agent/sandbox/interface.ts +++ b/codex-cli/src/utils/agent/sandbox/interface.ts @@ -18,6 +18,8 @@ export type ExecResult = { stdout: string; stderr: string; exitCode: number; + /** PID of the spawned process. 0 if spawn failed */ + pid: number; }; /** diff --git a/codex-cli/src/utils/agent/sandbox/raw-exec.ts b/codex-cli/src/utils/agent/sandbox/raw-exec.ts index 9e7ce41b32a..5e152a670df 100644 --- a/codex-cli/src/utils/agent/sandbox/raw-exec.ts +++ b/codex-cli/src/utils/agent/sandbox/raw-exec.ts @@ -41,6 +41,7 @@ export function exec( stdout: "", stderr: "command[0] is not a string", exitCode: 1, + pid: 0, }); } @@ -124,7 +125,7 @@ export function exec( if (!child.killed) { killTarget("SIGKILL"); } - }, 2000).unref(); + }, 250).unref(); }; if (abortSignal.aborted) { abortHandler(); @@ -186,6 +187,7 @@ export function exec( stdout, stderr, exitCode, + pid: child.pid ?? 0, }; resolve( addTruncationWarningsIfNecessary( @@ -201,6 +203,7 @@ export function exec( stdout: "", stderr: String(err), exitCode: 1, + pid: child.pid ?? 0, }; resolve( addTruncationWarningsIfNecessary( @@ -224,7 +227,7 @@ function addTruncationWarningsIfNecessary( if (!hitMaxStdout && !hitMaxStderr) { return execResult; } else { - const { stdout, stderr, exitCode } = execResult; + const { stdout, stderr, exitCode, pid } = execResult; return { stdout: hitMaxStdout ? stdout + "\n\n[Output truncated: too many lines or bytes]" @@ -233,6 +236,7 @@ function addTruncationWarningsIfNecessary( ? stderr + "\n\n[Output truncated: too many lines or bytes]" : stderr, exitCode, + pid, }; } } 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", }, }; diff --git a/codex-cli/tests/cancel-exec.test.ts b/codex-cli/tests/cancel-exec.test.ts index 86ff15d0ee8..23631feb138 100644 --- a/codex-cli/tests/cancel-exec.test.ts +++ b/codex-cli/tests/cancel-exec.test.ts @@ -25,6 +25,7 @@ describe("exec cancellation", () => { abortController.abort(); const result = await promise; + expect(result.pid).toBeGreaterThan(0); const durationMs = Date.now() - start; // The process should have been terminated rapidly (well under the 5s the @@ -49,6 +50,7 @@ describe("exec cancellation", () => { const cmd = ["node", "-e", "console.log('finished')"]; const result = await rawExec(cmd, {}, config, abortController.signal); + expect(result.pid).toBeGreaterThan(0); expect(result.exitCode).toBe(0); expect(result.stdout.trim()).toBe("finished"); diff --git a/codex-cli/tests/invalid-command-handling.test.ts b/codex-cli/tests/invalid-command-handling.test.ts index c36f8aea291..cd0e129e0b4 100644 --- a/codex-cli/tests/invalid-command-handling.test.ts +++ b/codex-cli/tests/invalid-command-handling.test.ts @@ -11,6 +11,7 @@ describe("rawExec – invalid command handling", () => { const cmd = ["definitely-not-a-command-1234567890"]; const config = { model: "any", instructions: "" } as AppConfig; const result = await rawExec(cmd, {}, config); + expect(result.pid).toBe(0); expect(result.exitCode).not.toBe(0); expect(result.stderr.length).toBeGreaterThan(0); diff --git a/codex-cli/tests/raw-exec-process-group.test.ts b/codex-cli/tests/raw-exec-process-group.test.ts index 11db40116b6..16249d7ef94 100644 --- a/codex-cli/tests/raw-exec-process-group.test.ts +++ b/codex-cli/tests/raw-exec-process-group.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import fs from "fs"; import { exec as rawExec } from "../src/utils/agent/sandbox/raw-exec.js"; import type { AppConfig } from "src/utils/config.js"; @@ -37,7 +38,11 @@ describe("rawExec – abort kills entire process group", () => { // - spawns a background `sleep 30` // - prints the PID of the `sleep` // - waits for `sleep` to exit - const { stdout, exitCode } = await (async () => { + const { + stdout, + exitCode, + pid: rootPid, + } = await (async () => { const p = rawExec(cmd, {}, config, abortController.signal); // Give Bash a tiny bit of time to start and print the PID. @@ -52,6 +57,7 @@ describe("rawExec – abort kills entire process group", () => { // We expect a non‑zero exit code because the process was killed. expect(exitCode).not.toBe(0); + expect(rootPid).toBeGreaterThan(0); // Extract the PID of the sleep process that bash printed const pid = Number(stdout.trim().match(/^\d+/)?.[0]); @@ -68,11 +74,19 @@ describe("rawExec – abort kills entire process group", () => { * @throws {Error} If the process is still alive after 500ms */ async function ensureProcessGone(pid: number) { - const timeout = 500; + const timeout = 1000; const deadline = Date.now() + timeout; while (Date.now() < deadline) { try { process.kill(pid, 0); // check if process still exists + try { + const stat = await fs.promises.readFile(`/proc/${pid}/stat`, "utf8"); + if (stat.split(" ")[2] === "Z") { + return; // zombie processes are effectively dead + } + } catch { + /* ignore */ + } await new Promise((r) => setTimeout(r, 50)); // wait and retry } catch (e: any) { if (e.code === "ESRCH") { @@ -81,6 +95,23 @@ async function ensureProcessGone(pid: number) { throw e; // unexpected error — rethrow } } + try { + process.kill(pid, "SIGKILL"); + } catch { + /* ignore */ + } + const extraDeadline = Date.now() + 250; + while (Date.now() < extraDeadline) { + try { + process.kill(pid, 0); + await new Promise((r) => setTimeout(r, 50)); + } catch (e: any) { + if (e.code === "ESRCH") { + return; + } + throw e; + } + } throw new Error( `Process with PID ${pid} failed to terminate within ${timeout}ms`, ); From 472cd31626fe986b1004bb70a501ea94059c85e2 Mon Sep 17 00:00:00 2001 From: notlikejuice <158367215+notlikejuice@users.noreply.github.com> Date: Sun, 15 Jun 2025 23:09:42 +0200 Subject: [PATCH 2/2] Use correct GitHub Copilot naming --- README.md | 2 +- codex-cli/src/utils/providers.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 045f60fe0f6..c9b84f9cd66 100644 --- a/README.md +++ b/README.md @@ -443,7 +443,7 @@ Below is a comprehensive example of `config.json` with multiple custom providers "envKey": "ARCEEAI_API_KEY" }, "githubcopilot": { - "name": "GithubCopilot", + "name": "GitHub Copilot", "baseURL": "https://api.githubcopilot.com", "envKey": "GITHUBCOPILOT_API_KEY" } diff --git a/codex-cli/src/utils/providers.ts b/codex-cli/src/utils/providers.ts index 88da52097ef..1152980c8b5 100644 --- a/codex-cli/src/utils/providers.ts +++ b/codex-cli/src/utils/providers.ts @@ -53,7 +53,7 @@ export const providers: Record< envKey: "ARCEEAI_API_KEY", }, githubcopilot: { - name: "GithubCopilot", + name: "GitHub Copilot", baseURL: "https://api.githubcopilot.com", envKey: "GITHUBCOPILOT_API_KEY", },