Skip to content

Commit 2f06611

Browse files
authored
Merge pull request #4 from notlikejuice/codex/add-github-copilot-support-to-codex-cli
Add GitHub Copilot provider support
2 parents b451a82 + 116dac9 commit 2f06611

File tree

5 files changed

+237
-28
lines changed

5 files changed

+237
-28
lines changed

codex-cli/src/cli.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ import {
3939
} from "./utils/config";
4040
import {
4141
getApiKey as fetchApiKey,
42+
getGithubCopilotApiKey as fetchGithubCopilotApiKey,
4243
maybeRedeemCredits,
43-
fetchGithubCopilotApiKey,
4444
} from "./utils/get-api-key";
4545
import { createInputItem } from "./utils/input-utils";
4646
import { initLogger } from "./utils/logger/log";
@@ -323,12 +323,38 @@ try {
323323
if (data.OPENAI_API_KEY && !expired) {
324324
apiKey = data.OPENAI_API_KEY;
325325
}
326+
if (
327+
data.GITHUBCOPILOT_API_KEY &&
328+
provider.toLowerCase() === "githubcopilot"
329+
) {
330+
apiKey = data.GITHUBCOPILOT_API_KEY;
331+
}
326332
}
327333
} catch {
328334
// ignore errors
329335
}
330336

331-
if (cli.flags.login) {
337+
if (provider.toLowerCase() === "githubcopilot" && !apiKey) {
338+
apiKey = await fetchGithubCopilotApiKey();
339+
try {
340+
const home = os.homedir();
341+
const authDir = path.join(home, ".codex");
342+
const authFile = path.join(authDir, "auth.json");
343+
fs.writeFileSync(
344+
authFile,
345+
JSON.stringify(
346+
{
347+
GITHUBCOPILOT_API_KEY: apiKey,
348+
},
349+
null,
350+
2,
351+
),
352+
"utf-8",
353+
);
354+
} catch {
355+
/* ignore */
356+
}
357+
} else if (cli.flags.login) {
332358
if (provider.toLowerCase() === "githubcopilot") {
333359
apiKey = await fetchGithubCopilotApiKey();
334360
} else {
@@ -353,7 +379,7 @@ if (cli.flags.login) {
353379
}
354380
}
355381
// Ensure the API key is available as an environment variable for legacy code
356-
process.env["OPENAI_API_KEY"] = apiKey;
382+
process.env[`${provider.toUpperCase()}_API_KEY`] = apiKey;
357383

358384
if (cli.flags.free) {
359385
// eslint-disable-next-line no-console

codex-cli/src/utils/agent/agent-loop.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
} from "../session.js";
3232
import { applyPatchToolInstructions } from "./apply-patch.js";
3333
import { handleExecCommand } from "./handle-exec-command.js";
34+
import { GithubCopilotClient } from "../openai-client.js";
3435
import { HttpsProxyAgent } from "https-proxy-agent";
3536
import { spawnSync } from "node:child_process";
3637
import { randomUUID } from "node:crypto";
@@ -350,6 +351,24 @@ export class AgentLoop {
350351
});
351352
}
352353

354+
if (this.provider.toLowerCase() === "githubcopilot") {
355+
this.oai = new GithubCopilotClient({
356+
...(apiKey ? { apiKey } : {}),
357+
baseURL,
358+
defaultHeaders: {
359+
originator: ORIGIN,
360+
version: CLI_VERSION,
361+
session_id: this.sessionId,
362+
...(OPENAI_ORGANIZATION
363+
? { "OpenAI-Organization": OPENAI_ORGANIZATION }
364+
: {}),
365+
...(OPENAI_PROJECT ? { "OpenAI-Project": OPENAI_PROJECT } : {}),
366+
},
367+
httpAgent: PROXY_URL ? new HttpsProxyAgent(PROXY_URL) : undefined,
368+
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
369+
});
370+
}
371+
353372
setSessionId(this.sessionId);
354373
setCurrentModel(this.model);
355374

codex-cli/src/utils/get-api-key.tsx

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import type { Choice } from "./get-api-key-components";
22
import type { Request, Response } from "express";
33

44
import { ApiKeyPrompt, WaitingForAuth } from "./get-api-key-components";
5+
import { GithubCopilotClient } from "./openai-client.js";
6+
import Spinner from "../components/vendor/ink-spinner.js";
57
import chalk from "chalk";
68
import express from "express";
79
import fs from "fs/promises";
8-
import { render } from "ink";
10+
import { Box, Text, render } from "ink";
911
import crypto from "node:crypto";
1012
import { URL } from "node:url";
1113
import open from "open";
@@ -763,26 +765,29 @@ export async function getApiKey(
763765
}
764766
}
765767

766-
export { maybeRedeemCredits };
767-
768-
export async function fetchGithubCopilotApiKey(): Promise<string> {
769-
if (process.env["GITHUB_COPILOT_TOKEN"]) {
770-
return process.env["GITHUB_COPILOT_TOKEN"]!;
771-
}
772-
773-
const choice = await promptUserForChoice();
774-
if (choice.type === "apikey") {
775-
process.env["GITHUB_COPILOT_TOKEN"] = choice.key;
776-
return choice.key;
777-
}
778-
779-
// Sign in via GitHub is not yet supported; instruct the user
780-
// eslint-disable-next-line no-console
781-
console.error(
782-
"\n" +
783-
"GitHub OAuth login is not yet implemented for Codex. " +
784-
"Please generate a token manually and set it as GITHUB_COPILOT_TOKEN." +
785-
"\n"
768+
export async function getGithubCopilotApiKey(): Promise<string> {
769+
const { device_code, user_code, verification_uri } =
770+
await GithubCopilotClient.getLoginURL();
771+
const spinner = render(
772+
<Box flexDirection="row" marginTop={1}>
773+
<Spinner type="ball" />
774+
<Text>
775+
{" "}
776+
Please visit {verification_uri} and enter code {user_code}
777+
</Text>
778+
</Box>,
786779
);
787-
process.exit(1);
780+
try {
781+
const key = await GithubCopilotClient.pollForAccessToken(device_code);
782+
spinner.clear();
783+
spinner.unmount();
784+
process.env["GITHUBCOPILOT_API_KEY"] = key;
785+
return key;
786+
} catch (err) {
787+
spinner.clear();
788+
spinner.unmount();
789+
throw err;
790+
}
788791
}
792+
793+
export { maybeRedeemCredits };

codex-cli/src/utils/openai-client.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { AppConfig } from "./config.js";
2+
import type { ClientOptions } from "openai";
3+
import type * as Core from "openai/core";
24

35
import {
46
getBaseUrl,
@@ -9,6 +11,7 @@ import {
911
OPENAI_PROJECT,
1012
} from "./config.js";
1113
import OpenAI, { AzureOpenAI } from "openai";
14+
import * as Errors from "openai/error";
1215

1316
type OpenAIClientConfig = {
1417
provider: string;
@@ -42,10 +45,166 @@ export function createOpenAIClient(
4245
});
4346
}
4447

48+
if (config.provider?.toLowerCase() === "githubcopilot") {
49+
return new GithubCopilotClient({
50+
apiKey: getApiKey(config.provider),
51+
baseURL: getBaseUrl(config.provider),
52+
timeout: OPENAI_TIMEOUT_MS,
53+
defaultHeaders: headers,
54+
});
55+
}
56+
4557
return new OpenAI({
4658
apiKey: getApiKey(config.provider),
4759
baseURL: getBaseUrl(config.provider),
4860
timeout: OPENAI_TIMEOUT_MS,
4961
defaultHeaders: headers,
5062
});
5163
}
64+
65+
export class GithubCopilotClient extends OpenAI {
66+
private copilotToken: string | null = null;
67+
private copilotTokenExpiration = new Date();
68+
private githubAPIKey: string;
69+
70+
constructor(opts: ClientOptions = {}) {
71+
super(opts);
72+
if (!opts.apiKey) {
73+
throw new Errors.OpenAIError("missing github copilot token");
74+
}
75+
this.githubAPIKey = opts.apiKey;
76+
}
77+
78+
private async _getGithubCopilotToken(): Promise<string | undefined> {
79+
if (
80+
this.copilotToken &&
81+
this.copilotTokenExpiration.getTime() > Date.now()
82+
) {
83+
return this.copilotToken;
84+
}
85+
const resp = await fetch(
86+
"https://api.github.com/copilot_internal/v2/token",
87+
{
88+
method: "GET",
89+
headers: GithubCopilotClient._mergeGithubHeaders({
90+
"Authorization": `bearer ${this.githubAPIKey}`,
91+
"Accept": "application/json",
92+
"Content-Type": "application/json",
93+
}),
94+
},
95+
);
96+
if (!resp.ok) {
97+
const text = await resp.text();
98+
throw new Error("unable to get github copilot auth token: " + text);
99+
}
100+
const text = await resp.text();
101+
const { token, refresh_in } = JSON.parse(text);
102+
if (typeof token !== "string" || typeof refresh_in !== "number") {
103+
throw new Errors.OpenAIError(
104+
`unexpected response from copilot auth: ${text}`,
105+
);
106+
}
107+
this.copilotToken = token;
108+
this.copilotTokenExpiration = new Date(Date.now() + refresh_in * 1000);
109+
return token;
110+
}
111+
112+
protected override authHeaders(
113+
_opts: Core.FinalRequestOptions,
114+
): Core.Headers {
115+
return {};
116+
}
117+
118+
protected override async prepareOptions(
119+
opts: Core.FinalRequestOptions<unknown>,
120+
): Promise<void> {
121+
const token = await this._getGithubCopilotToken();
122+
opts.headers ??= {};
123+
if (token) {
124+
opts.headers["Authorization"] = `Bearer ${token}`;
125+
opts.headers = GithubCopilotClient._mergeGithubHeaders(opts.headers);
126+
} else {
127+
throw new Errors.OpenAIError("Unable to handle auth");
128+
}
129+
return super.prepareOptions(opts);
130+
}
131+
132+
static async getLoginURL(): Promise<{
133+
device_code: string;
134+
user_code: string;
135+
verification_uri: string;
136+
}> {
137+
const resp = await fetch("https://github.com/login/device/code", {
138+
method: "POST",
139+
headers: this._mergeGithubHeaders({
140+
"Content-Type": "application/json",
141+
"accept": "application/json",
142+
}),
143+
body: JSON.stringify({
144+
client_id: "Iv1.b507a08c87ecfe98",
145+
scope: "read:user",
146+
}),
147+
});
148+
if (!resp.ok) {
149+
const text = await resp.text();
150+
throw new Errors.OpenAIError("Unable to get login device code: " + text);
151+
}
152+
return resp.json();
153+
}
154+
155+
static async pollForAccessToken(deviceCode: string): Promise<string> {
156+
/*eslint no-await-in-loop: "off"*/
157+
const MAX_ATTEMPTS = 36;
158+
let lastErr: unknown = null;
159+
for (let i = 0; i < MAX_ATTEMPTS; ++i) {
160+
try {
161+
const resp = await fetch(
162+
"https://github.com/login/oauth/access_token",
163+
{
164+
method: "POST",
165+
headers: this._mergeGithubHeaders({
166+
"Content-Type": "application/json",
167+
"accept": "application/json",
168+
}),
169+
body: JSON.stringify({
170+
client_id: "Iv1.b507a08c87ecfe98",
171+
device_code: deviceCode,
172+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
173+
}),
174+
},
175+
);
176+
if (!resp.ok) {
177+
continue;
178+
}
179+
const info = await resp.json();
180+
if (info.access_token) {
181+
return info.access_token as string;
182+
} else if (info.error === "authorization_pending") {
183+
lastErr = null;
184+
} else {
185+
throw new Errors.OpenAIError(
186+
"unexpected response when polling for access token: " +
187+
JSON.stringify(info),
188+
);
189+
}
190+
} catch (err) {
191+
lastErr = err;
192+
}
193+
await new Promise((resolve) => setTimeout(resolve, 5_000));
194+
}
195+
throw new Errors.OpenAIError(
196+
"timed out waiting for access token",
197+
lastErr != null ? { cause: lastErr } : {},
198+
);
199+
}
200+
201+
private static _mergeGithubHeaders<
202+
T extends Core.Headers | Record<string, string>,
203+
>(headers: T): T {
204+
const copy = { ...headers } as Record<string, string> & T;
205+
copy["User-Agent"] = "GithubCopilot/1.155.0";
206+
copy["editor-version"] = "vscode/1.85.1";
207+
copy["editor-plugin-version"] = "copilot/1.155.0";
208+
return copy as T;
209+
}
210+
}

codex-cli/src/utils/providers.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ export const providers: Record<
5353
envKey: "ARCEEAI_API_KEY",
5454
},
5555
githubcopilot: {
56-
name: "GitHubCopilot",
57-
baseURL: "https://copilot-proxy.githubusercontent.com/v1",
58-
envKey: "GITHUB_COPILOT_TOKEN",
56+
name: "GithubCopilot",
57+
baseURL: "https://api.githubcopilot.com",
58+
envKey: "GITHUBCOPILOT_API_KEY",
5959
},
6060
};

0 commit comments

Comments
 (0)