diff --git a/codex-cli/src/cli.tsx b/codex-cli/src/cli.tsx index bf9385f8..7289057f 100644 --- a/codex-cli/src/cli.tsx +++ b/codex-cli/src/cli.tsx @@ -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(s) to image files to include as input -v, --view 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 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"]); diff --git a/codex-cli/src/utils/get-api-key.tsx b/codex-cli/src/utils/get-api-key.tsx index ad5ce1c0..c35dd2a5 100644 --- a/codex-cli/src/utils/get-api-key.tsx +++ b/codex-cli/src/utils/get-api-key.tsx @@ -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 { + 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(); 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 };