add: codex --login + codex --free (#998)
## Summary - add `--login` and `--free` flags to cli help - handle `--login` and `--free` logic in cli - factor out redeem flow into `maybeRedeemCredits` - call new helper from login callback
This commit is contained in:
@@ -24,7 +24,10 @@ import {
|
|||||||
PRETTY_PRINT,
|
PRETTY_PRINT,
|
||||||
INSTRUCTIONS_FILEPATH,
|
INSTRUCTIONS_FILEPATH,
|
||||||
} from "./utils/config";
|
} from "./utils/config";
|
||||||
import { getApiKey as fetchApiKey } from "./utils/get-api-key";
|
import {
|
||||||
|
getApiKey as fetchApiKey,
|
||||||
|
maybeRedeemCredits,
|
||||||
|
} from "./utils/get-api-key";
|
||||||
import { createInputItem } from "./utils/input-utils";
|
import { createInputItem } from "./utils/input-utils";
|
||||||
import { initLogger } from "./utils/logger/log";
|
import { initLogger } from "./utils/logger/log";
|
||||||
import { isModelSupportedForResponses } from "./utils/model-utils.js";
|
import { isModelSupportedForResponses } from "./utils/model-utils.js";
|
||||||
@@ -63,6 +66,8 @@ const cli = meow(
|
|||||||
-i, --image <path> Path(s) to image files to include as input
|
-i, --image <path> Path(s) to image files to include as input
|
||||||
-v, --view <rollout> Inspect a previously saved rollout instead of starting a session
|
-v, --view <rollout> Inspect a previously saved rollout instead of starting a session
|
||||||
--history Browse previous sessions
|
--history Browse previous sessions
|
||||||
|
--login Start a new sign in flow
|
||||||
|
--free Retry redeeming free credits
|
||||||
-q, --quiet Non-interactive mode that only prints the assistant's final output
|
-q, --quiet Non-interactive mode that only prints the assistant's final output
|
||||||
-c, --config Open the instructions file in your editor
|
-c, --config Open the instructions file in your editor
|
||||||
-w, --writable-root <path> Writable folder for sandbox in full-auto mode (can be specified multiple times)
|
-w, --writable-root <path> Writable folder for sandbox in full-auto mode (can be specified multiple times)
|
||||||
@@ -108,6 +113,8 @@ const cli = meow(
|
|||||||
version: { type: "boolean", description: "Print version and exit" },
|
version: { type: "boolean", description: "Print version and exit" },
|
||||||
view: { type: "string" },
|
view: { type: "string" },
|
||||||
history: { type: "boolean", description: "Browse previous sessions" },
|
history: { type: "boolean", description: "Browse previous sessions" },
|
||||||
|
login: { type: "boolean", description: "Force a new sign in flow" },
|
||||||
|
free: { type: "boolean", description: "Retry redeeming free credits" },
|
||||||
model: { type: "string", aliases: ["m"] },
|
model: { type: "string", aliases: ["m"] },
|
||||||
provider: { type: "string", aliases: ["p"] },
|
provider: { type: "string", aliases: ["p"] },
|
||||||
image: { type: "string", isMultiple: true, aliases: ["i"] },
|
image: { type: "string", isMultiple: true, aliases: ["i"] },
|
||||||
@@ -279,6 +286,13 @@ const client = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let apiKey = "";
|
let apiKey = "";
|
||||||
|
let savedTokens:
|
||||||
|
| {
|
||||||
|
id_token?: string;
|
||||||
|
access_token?: string;
|
||||||
|
refresh_token: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
// Try to load existing auth file if present
|
// Try to load existing auth file if present
|
||||||
try {
|
try {
|
||||||
@@ -287,6 +301,7 @@ try {
|
|||||||
const authFile = path.join(authDir, "auth.json");
|
const authFile = path.join(authDir, "auth.json");
|
||||||
if (fs.existsSync(authFile)) {
|
if (fs.existsSync(authFile)) {
|
||||||
const data = JSON.parse(fs.readFileSync(authFile, "utf-8"));
|
const data = JSON.parse(fs.readFileSync(authFile, "utf-8"));
|
||||||
|
savedTokens = data.tokens;
|
||||||
const lastRefreshTime = data.last_refresh
|
const lastRefreshTime = data.last_refresh
|
||||||
? new Date(data.last_refresh).getTime()
|
? new Date(data.last_refresh).getTime()
|
||||||
: 0;
|
: 0;
|
||||||
@@ -299,12 +314,36 @@ try {
|
|||||||
// ignore errors
|
// ignore errors
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!apiKey) {
|
if (cli.flags.login) {
|
||||||
|
apiKey = await fetchApiKey(client.issuer, client.client_id);
|
||||||
|
try {
|
||||||
|
const home = os.homedir();
|
||||||
|
const authDir = path.join(home, ".codex");
|
||||||
|
const authFile = path.join(authDir, "auth.json");
|
||||||
|
if (fs.existsSync(authFile)) {
|
||||||
|
const data = JSON.parse(fs.readFileSync(authFile, "utf-8"));
|
||||||
|
savedTokens = data.tokens;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
} else if (!apiKey) {
|
||||||
apiKey = await fetchApiKey(client.issuer, client.client_id);
|
apiKey = await fetchApiKey(client.issuer, client.client_id);
|
||||||
}
|
}
|
||||||
// Ensure the API key is available as an environment variable for legacy code
|
// Ensure the API key is available as an environment variable for legacy code
|
||||||
process.env["OPENAI_API_KEY"] = apiKey;
|
process.env["OPENAI_API_KEY"] = apiKey;
|
||||||
|
|
||||||
|
if (cli.flags.free && savedTokens?.refresh_token) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`${chalk.bold("codex --free")} attempting to redeem credits...`);
|
||||||
|
await maybeRedeemCredits(
|
||||||
|
client.issuer,
|
||||||
|
client.client_id,
|
||||||
|
savedTokens.refresh_token,
|
||||||
|
savedTokens.id_token,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Set of providers that don't require API keys
|
// Set of providers that don't require API keys
|
||||||
const NO_API_KEY_REQUIRED = new Set(["ollama"]);
|
const NO_API_KEY_REQUIRED = new Set(["ollama"]);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { Choice } from "./get-api-key-components";
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
|
|
||||||
import { ApiKeyPrompt, WaitingForAuth } from "./get-api-key-components";
|
import { ApiKeyPrompt, WaitingForAuth } from "./get-api-key-components";
|
||||||
import { clearTerminal } from "./terminal";
|
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
@@ -52,11 +51,15 @@ async function getOidcConfiguration(
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface IDTokenClaims {
|
interface IDTokenClaims {
|
||||||
|
"exp": number;
|
||||||
"https://api.openai.com/auth": {
|
"https://api.openai.com/auth": {
|
||||||
organization_id: string;
|
organization_id: string;
|
||||||
project_id: string;
|
project_id: string;
|
||||||
completed_platform_onboarding: boolean;
|
completed_platform_onboarding: boolean;
|
||||||
is_org_owner: boolean;
|
is_org_owner: boolean;
|
||||||
|
chatgpt_subscription_active_start: string;
|
||||||
|
chatgpt_subscription_active_until: string;
|
||||||
|
chatgpt_plan_type: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +81,182 @@ function generatePKCECodes(): {
|
|||||||
return { code_verifier, code_challenge };
|
return { code_verifier, code_challenge };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function maybeRedeemCredits(
|
||||||
|
issuer: string,
|
||||||
|
clientId: string,
|
||||||
|
refreshToken: string,
|
||||||
|
idToken?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
let currentIdToken = idToken;
|
||||||
|
let idClaims: IDTokenClaims | undefined;
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentIdToken &&
|
||||||
|
typeof currentIdToken === "string" &&
|
||||||
|
currentIdToken.split(".")[1]
|
||||||
|
) {
|
||||||
|
idClaims = JSON.parse(
|
||||||
|
Buffer.from(currentIdToken.split(".")[1]!, "base64url").toString(
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
) as IDTokenClaims;
|
||||||
|
} else {
|
||||||
|
currentIdToken = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate idToken expiration
|
||||||
|
// if expired, attempt token-exchange for a fresh idToken
|
||||||
|
if (!idClaims || !idClaims.exp || Date.now() >= idClaims.exp * 1000) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(chalk.dim("Refreshing credentials..."));
|
||||||
|
try {
|
||||||
|
const refreshRes = await fetch("https://auth.openai.com/oauth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: clientId,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
scope: "openid profile email",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!refreshRes.ok) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(
|
||||||
|
`Failed to refresh credentials: ${refreshRes.status} ${refreshRes.statusText}\n${chalk.dim(await refreshRes.text())}`,
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(
|
||||||
|
`Please sign in again to redeem credits: ${chalk.bold("codex --login")}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const refreshData = (await refreshRes.json()) as { id_token: string };
|
||||||
|
currentIdToken = refreshData.id_token;
|
||||||
|
idClaims = JSON.parse(
|
||||||
|
Buffer.from(currentIdToken.split(".")[1]!, "base64url").toString(
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
) as IDTokenClaims;
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn("Unable to refresh ID token via token-exchange:", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm the subscription is active for more than 7 days
|
||||||
|
const subStart =
|
||||||
|
idClaims["https://api.openai.com/auth"]
|
||||||
|
?.chatgpt_subscription_active_start;
|
||||||
|
if (
|
||||||
|
typeof subStart === "string" &&
|
||||||
|
Date.now() - new Date(subStart).getTime() < 7 * 24 * 60 * 60 * 1000
|
||||||
|
) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(
|
||||||
|
"Sorry, your subscription must be active for more than 7 days to redeem credits.\nMore info: " +
|
||||||
|
chalk.dim("https://help.openai.com/en/articles/11381614") +
|
||||||
|
chalk.bold(
|
||||||
|
"\nPlease try again on " +
|
||||||
|
new Date(
|
||||||
|
new Date(subStart).getTime() + 7 * 24 * 60 * 60 * 1000,
|
||||||
|
).toLocaleDateString() +
|
||||||
|
" " +
|
||||||
|
new Date(
|
||||||
|
new Date(subStart).getTime() + 7 * 24 * 60 * 60 * 1000,
|
||||||
|
).toLocaleTimeString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = Boolean(
|
||||||
|
idClaims["https://api.openai.com/auth"]?.completed_platform_onboarding,
|
||||||
|
);
|
||||||
|
const isOwner = Boolean(
|
||||||
|
idClaims["https://api.openai.com/auth"]?.is_org_owner,
|
||||||
|
);
|
||||||
|
const needsSetup = !completed && isOwner;
|
||||||
|
|
||||||
|
const planType = idClaims["https://api.openai.com/auth"]
|
||||||
|
?.chatgpt_plan_type as string | undefined;
|
||||||
|
|
||||||
|
if (needsSetup || !(planType === "plus" || planType === "pro")) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(
|
||||||
|
"Users with Plus or Pro subscriptions can redeem free API credits.\nMore info: " +
|
||||||
|
chalk.dim("https://help.openai.com/en/articles/11381614"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiHost =
|
||||||
|
issuer === "https://auth.openai.com"
|
||||||
|
? "https://api.openai.com"
|
||||||
|
: "https://api.openai.org";
|
||||||
|
|
||||||
|
const redeemRes = await fetch(`${apiHost}/v1/billing/redeem_credits`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id_token: idToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!redeemRes.ok) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(
|
||||||
|
`Credit redemption request failed: ${redeemRes.status} ${redeemRes.statusText}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redeemData = (await redeemRes.json()) as {
|
||||||
|
granted_chatgpt_subscriber_api_credits?: number;
|
||||||
|
};
|
||||||
|
const granted = redeemData?.granted_chatgpt_subscriber_api_credits ?? 0;
|
||||||
|
if (granted > 0) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
chalk.green(
|
||||||
|
`${chalk.bold(
|
||||||
|
`Thanks for being a ChatGPT ${
|
||||||
|
planType === "plus" ? "Plus" : "Pro"
|
||||||
|
} subscriber!`,
|
||||||
|
)}\nIf you haven't already redeemed, you should receive ${
|
||||||
|
planType === "plus" ? "$5" : "$50"
|
||||||
|
} in API credits\nCredits: ${chalk.dim(chalk.underline("https://platform.openai.com/settings/organization/billing/credit-grants"))}\nMore info: ${chalk.dim(chalk.underline("https://help.openai.com/en/articles/11381614"))}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
chalk.green(
|
||||||
|
`It looks like no credits were granted:\n${JSON.stringify(
|
||||||
|
redeemData,
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\nCredits: ${chalk.dim(
|
||||||
|
chalk.underline(
|
||||||
|
"https://platform.openai.com/settings/organization/billing/credit-grants",
|
||||||
|
),
|
||||||
|
)}\nMore info: ${chalk.dim(
|
||||||
|
chalk.underline("https://help.openai.com/en/articles/11381614"),
|
||||||
|
)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (parseErr) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn("Unable to parse credit redemption response:", parseErr);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn("Unable to redeem ChatGPT subscriber API credits:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCallback(
|
async function handleCallback(
|
||||||
req: Request,
|
req: Request,
|
||||||
issuer: string,
|
issuer: string,
|
||||||
@@ -122,9 +301,9 @@ async function handleCallback(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tokenData = (await tokenRes.json()) as {
|
const tokenData = (await tokenRes.json()) as {
|
||||||
access_token: string;
|
|
||||||
id_token: string;
|
id_token: string;
|
||||||
refresh_token?: string;
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const idTokenParts = tokenData.id_token.split(".");
|
const idTokenParts = tokenData.id_token.split(".");
|
||||||
@@ -231,57 +410,12 @@ async function handleCallback(
|
|||||||
console.warn("Unable to save auth file:", err);
|
console.warn("Unable to save auth file:", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
await maybeRedeemCredits(
|
||||||
!needsSetup &&
|
issuer,
|
||||||
(chatgptPlanType === "plus" || chatgptPlanType === "pro")
|
clientId,
|
||||||
) {
|
tokenData.refresh_token,
|
||||||
const apiHost =
|
tokenData.id_token,
|
||||||
issuer === "https://auth.openai.com"
|
);
|
||||||
? "https://api.openai.com"
|
|
||||||
: "https://api.openai.org";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const redeemRes = await fetch(`${apiHost}/v1/billing/redeem_credits`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ id_token: tokenData.id_token }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!redeemRes.ok) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn(
|
|
||||||
`Credit redemption request failed: ${redeemRes.status} ${redeemRes.statusText}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Attempt to parse the JSON response and surface a success message
|
|
||||||
try {
|
|
||||||
const redeemData = (await redeemRes.json()) as {
|
|
||||||
granted_chatgpt_subscriber_api_credits?: number;
|
|
||||||
};
|
|
||||||
const granted =
|
|
||||||
redeemData?.granted_chatgpt_subscriber_api_credits ?? 0;
|
|
||||||
if (granted > 0) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(
|
|
||||||
chalk.green(
|
|
||||||
`\u2728 Granted ${chatgptPlanType === "plus" ? "$5" : "$50"} in API credits for being a ChatGPT ${
|
|
||||||
chatgptPlanType === "plus" ? "Plus" : "Pro"
|
|
||||||
} subscriber!`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (parseErr) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn("Unable to parse credit redemption response:", parseErr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn("Unable to redeem ChatGPT subscriber API credits:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
access_token: exchanged.access_token,
|
access_token: exchanged.access_token,
|
||||||
@@ -591,12 +725,15 @@ export async function getApiKey(
|
|||||||
const spinner = render(<WaitingForAuth />);
|
const spinner = render(<WaitingForAuth />);
|
||||||
try {
|
try {
|
||||||
const key = await signInFlow(issuer, clientId);
|
const key = await signInFlow(issuer, clientId);
|
||||||
|
spinner.clear();
|
||||||
spinner.unmount();
|
spinner.unmount();
|
||||||
clearTerminal();
|
|
||||||
process.env["OPENAI_API_KEY"] = key;
|
process.env["OPENAI_API_KEY"] = key;
|
||||||
return key;
|
return key;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
spinner.clear();
|
||||||
spinner.unmount();
|
spinner.unmount();
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { maybeRedeemCredits };
|
||||||
|
|||||||
Reference in New Issue
Block a user