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,
|
||||
INSTRUCTIONS_FILEPATH,
|
||||
} 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 { initLogger } from "./utils/logger/log";
|
||||
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
|
||||
-v, --view <rollout> Inspect a previously saved rollout instead of starting a session
|
||||
--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
|
||||
-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)
|
||||
@@ -108,6 +113,8 @@ const cli = meow(
|
||||
version: { type: "boolean", description: "Print version and exit" },
|
||||
view: { type: "string" },
|
||||
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"] },
|
||||
provider: { type: "string", aliases: ["p"] },
|
||||
image: { type: "string", isMultiple: true, aliases: ["i"] },
|
||||
@@ -279,6 +286,13 @@ const client = {
|
||||
};
|
||||
|
||||
let apiKey = "";
|
||||
let savedTokens:
|
||||
| {
|
||||
id_token?: string;
|
||||
access_token?: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
// Try to load existing auth file if present
|
||||
try {
|
||||
@@ -287,6 +301,7 @@ try {
|
||||
const authFile = path.join(authDir, "auth.json");
|
||||
if (fs.existsSync(authFile)) {
|
||||
const data = JSON.parse(fs.readFileSync(authFile, "utf-8"));
|
||||
savedTokens = data.tokens;
|
||||
const lastRefreshTime = data.last_refresh
|
||||
? new Date(data.last_refresh).getTime()
|
||||
: 0;
|
||||
@@ -299,12 +314,36 @@ try {
|
||||
// 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);
|
||||
}
|
||||
// Ensure the API key is available as an environment variable for legacy code
|
||||
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
|
||||
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 { ApiKeyPrompt, WaitingForAuth } from "./get-api-key-components";
|
||||
import { clearTerminal } from "./terminal";
|
||||
import chalk from "chalk";
|
||||
import express from "express";
|
||||
import fs from "fs/promises";
|
||||
@@ -52,11 +51,15 @@ async function getOidcConfiguration(
|
||||
}
|
||||
|
||||
interface IDTokenClaims {
|
||||
"exp": number;
|
||||
"https://api.openai.com/auth": {
|
||||
organization_id: string;
|
||||
project_id: string;
|
||||
completed_platform_onboarding: 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 };
|
||||
}
|
||||
|
||||
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(
|
||||
req: Request,
|
||||
issuer: string,
|
||||
@@ -122,9 +301,9 @@ async function handleCallback(
|
||||
}
|
||||
|
||||
const tokenData = (await tokenRes.json()) as {
|
||||
access_token: string;
|
||||
id_token: string;
|
||||
refresh_token?: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
};
|
||||
|
||||
const idTokenParts = tokenData.id_token.split(".");
|
||||
@@ -231,57 +410,12 @@ async function handleCallback(
|
||||
console.warn("Unable to save auth file:", err);
|
||||
}
|
||||
|
||||
if (
|
||||
!needsSetup &&
|
||||
(chatgptPlanType === "plus" || chatgptPlanType === "pro")
|
||||
) {
|
||||
const apiHost =
|
||||
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);
|
||||
}
|
||||
}
|
||||
await maybeRedeemCredits(
|
||||
issuer,
|
||||
clientId,
|
||||
tokenData.refresh_token,
|
||||
tokenData.id_token,
|
||||
);
|
||||
|
||||
return {
|
||||
access_token: exchanged.access_token,
|
||||
@@ -591,12 +725,15 @@ export async function getApiKey(
|
||||
const spinner = render(<WaitingForAuth />);
|
||||
try {
|
||||
const key = await signInFlow(issuer, clientId);
|
||||
spinner.clear();
|
||||
spinner.unmount();
|
||||
clearTerminal();
|
||||
process.env["OPENAI_API_KEY"] = key;
|
||||
return key;
|
||||
} catch (err) {
|
||||
spinner.clear();
|
||||
spinner.unmount();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export { maybeRedeemCredits };
|
||||
|
||||
Reference in New Issue
Block a user