Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions codex-cli/src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Copy link

Copilot AI Jun 15, 2025

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.

Suggested change
if (provider.toLowerCase() === "githubcopilot" && !apiKey) {
if (provider.toLowerCase() === "githubcopilot" && !apiKey && cli.flags.login) {

Copilot uses AI. Check for mistakes.
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 {
Expand All @@ -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
Expand Down
19 changes: 19 additions & 0 deletions codex-cli/src/utils/agent/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -350,6 +351,24 @@ export class AgentLoop {
});
}

if (this.provider.toLowerCase() === "githubcopilot") {
Copy link

Copilot AI Jun 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The GitHub Copilot client initialization mirrors logic in 'createOpenAIClient'; consider refactoring to reuse the factory for consistency and to reduce duplication.

Copilot uses AI. Check for mistakes.
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);

Expand Down
49 changes: 27 additions & 22 deletions codex-cli/src/utils/get-api-key.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -763,26 +765,29 @@ export async function getApiKey(
}
}

export { maybeRedeemCredits };

export async function fetchGithubCopilotApiKey(): Promise<string> {
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<string> {
const { device_code, user_code, verification_uri } =
await GithubCopilotClient.getLoginURL();
const spinner = render(
<Box flexDirection="row" marginTop={1}>
<Spinner type="ball" />
<Text>
{" "}
Please visit {verification_uri} and enter code {user_code}
</Text>
</Box>,
);
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 };
159 changes: 159 additions & 0 deletions codex-cli/src/utils/openai-client.ts
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,
Expand All @@ -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;
Expand Down Expand Up @@ -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 link

Copilot AI Jun 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The 'editor-version' header is hardcoded to a VSCode plugin version; consider reflecting the CLI’s version or making this header configurable.

Copilot uses AI. Check for mistakes.
copy["editor-plugin-version"] = "copilot/1.155.0";
return copy as T;
}
}
6 changes: 3 additions & 3 deletions codex-cli/src/utils/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
};