Skip to content

Commit 919a016

Browse files
committed
feat(cli): add 'codex auth best' command for automatic best account switching
Adds a new CLI command that automatically switches to the best standing account based on the forecast algorithm. Supports --live, --json, and --model flags. - Add 'best' to AUTH_SUBCOMMANDS in codex-routing.js - Implement runBest() function in codex-manager.ts - Add 'best' switch reason to schemas and types - Update help text and feature list Usage: codex auth best [--live] [--json] [--model <model>]
1 parent a5699d3 commit 919a016

7 files changed

Lines changed: 211 additions & 4 deletions

File tree

lib/accounts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export interface ManagedAccount {
9191
expires?: number;
9292
addedAt: number;
9393
lastUsed: number;
94-
lastSwitchReason?: "rate-limit" | "initial" | "rotation";
94+
lastSwitchReason?: "rate-limit" | "initial" | "rotation" | "best";
9595
lastRateLimitReason?: RateLimitReason;
9696
rateLimitResetTimes: RateLimitStateV3;
9797
coolingDownUntil?: number;

lib/codex-manager.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ function printUsage(): void {
292292
" codex auth list",
293293
" codex auth status",
294294
" codex auth switch <index>",
295+
" codex auth best [--json] [--model <model>]",
295296
" codex auth check",
296297
" codex auth features",
297298
" codex auth verify-flagged [--dry-run] [--json] [--no-restore]",
@@ -353,6 +354,7 @@ const IMPLEMENTED_FEATURES: ImplementedFeature[] = [
353354
{ id: 38, name: "Dashboard display customization" },
354355
{ id: 39, name: "Unified color/theme runtime (v2 UI)" },
355356
{ id: 40, name: "OAuth browser-first flow with manual callback fallback" },
357+
{ id: 41, name: "Auto-switch to best account command" },
356358
];
357359

358360
function runFeaturesReport(): number {
@@ -3976,6 +3978,207 @@ async function runSwitch(args: string[]): Promise<number> {
39763978
return 0;
39773979
}
39783980

3981+
async function runBest(args: string[]): Promise<number> {
3982+
const options: ForecastCliOptions = {
3983+
live: true, // Always use live probing like the dashboard's "Best Account" option
3984+
json: args.includes("--json") || args.includes("-j"),
3985+
model: "gpt-5-codex",
3986+
};
3987+
3988+
// Parse model if provided
3989+
for (let i = 0; i < args.length; i += 1) {
3990+
const arg = args[i];
3991+
if (!arg) continue;
3992+
if (arg === "--model" || arg === "-m") {
3993+
const value = args[i + 1];
3994+
if (value) {
3995+
options.model = value;
3996+
}
3997+
break;
3998+
}
3999+
if (arg.startsWith("--model=")) {
4000+
const value = arg.slice("--model=".length).trim();
4001+
if (value) {
4002+
options.model = value;
4003+
}
4004+
break;
4005+
}
4006+
}
4007+
4008+
setStoragePath(null);
4009+
const storage = await loadAccounts();
4010+
if (!storage || storage.accounts.length === 0) {
4011+
if (options.json) {
4012+
console.log(JSON.stringify({ error: "No accounts configured." }, null, 2));
4013+
} else {
4014+
console.log("No accounts configured.");
4015+
}
4016+
return 1;
4017+
}
4018+
4019+
const now = Date.now();
4020+
const refreshFailures = new Map<number, TokenFailure>();
4021+
const liveQuotaByIndex = new Map<number, Awaited<ReturnType<typeof fetchCodexQuotaSnapshot>>>();
4022+
4023+
// Always do live probing (like dashboard's "Best Account" forecast mode)
4024+
for (let i = 0; i < storage.accounts.length; i += 1) {
4025+
const account = storage.accounts[i];
4026+
if (!account || account.enabled === false) continue;
4027+
4028+
let probeAccessToken = account.accessToken;
4029+
let probeAccountId = account.accountId ?? extractAccountId(account.accessToken);
4030+
if (!hasUsableAccessToken(account, now)) {
4031+
const refreshResult = await queuedRefresh(account.refreshToken);
4032+
if (refreshResult.type !== "success") {
4033+
refreshFailures.set(i, {
4034+
...refreshResult,
4035+
message: normalizeFailureDetail(refreshResult.message, refreshResult.reason),
4036+
});
4037+
continue;
4038+
}
4039+
probeAccessToken = refreshResult.access;
4040+
probeAccountId = account.accountId ?? extractAccountId(refreshResult.access);
4041+
}
4042+
4043+
if (!probeAccessToken || !probeAccountId) continue;
4044+
4045+
try {
4046+
const liveQuota = await fetchCodexQuotaSnapshot({
4047+
accountId: probeAccountId,
4048+
accessToken: probeAccessToken,
4049+
model: options.model,
4050+
});
4051+
liveQuotaByIndex.set(i, liveQuota);
4052+
} catch {
4053+
// Ignore probe errors for best selection
4054+
}
4055+
}
4056+
4057+
const forecastInputs = storage.accounts.map((account, index) => ({
4058+
index,
4059+
account,
4060+
isCurrent: index === resolveActiveIndex(storage, "codex"),
4061+
now,
4062+
refreshFailure: refreshFailures.get(index),
4063+
liveQuota: liveQuotaByIndex.get(index),
4064+
}));
4065+
4066+
const forecastResults = evaluateForecastAccounts(forecastInputs);
4067+
const recommendation = recommendForecastAccount(forecastResults);
4068+
4069+
if (recommendation.recommendedIndex === null) {
4070+
if (options.json) {
4071+
console.log(JSON.stringify({ error: recommendation.reason }, null, 2));
4072+
} else {
4073+
console.log(`No best account available: ${recommendation.reason}`);
4074+
}
4075+
return 1;
4076+
}
4077+
4078+
const bestIndex = recommendation.recommendedIndex;
4079+
const bestAccount = storage.accounts[bestIndex];
4080+
if (!bestAccount) {
4081+
if (options.json) {
4082+
console.log(JSON.stringify({ error: "Best account not found." }, null, 2));
4083+
} else {
4084+
console.log("Best account not found.");
4085+
}
4086+
return 1;
4087+
}
4088+
4089+
// Check if already on best account
4090+
const currentIndex = resolveActiveIndex(storage, "codex");
4091+
if (currentIndex === bestIndex) {
4092+
if (options.json) {
4093+
console.log(JSON.stringify({
4094+
message: `Already on best account: ${formatAccountLabel(bestAccount, bestIndex)}`,
4095+
accountIndex: bestIndex + 1,
4096+
reason: recommendation.reason,
4097+
}, null, 2));
4098+
} else {
4099+
console.log(`Already on best account ${bestIndex + 1}: ${formatAccountLabel(bestAccount, bestIndex)}`);
4100+
console.log(`Reason: ${recommendation.reason}`);
4101+
}
4102+
return 0;
4103+
}
4104+
4105+
// Switch to best account (reuse runSwitch logic)
4106+
const targetIndex = bestIndex;
4107+
const parsed = targetIndex + 1;
4108+
storage.activeIndex = targetIndex;
4109+
storage.activeIndexByFamily = storage.activeIndexByFamily ?? {};
4110+
for (const family of MODEL_FAMILIES) {
4111+
storage.activeIndexByFamily[family] = targetIndex;
4112+
}
4113+
const wasDisabled = bestAccount.enabled === false;
4114+
if (wasDisabled) {
4115+
bestAccount.enabled = true;
4116+
}
4117+
const switchNow = Date.now();
4118+
let syncAccessToken = bestAccount.accessToken;
4119+
let syncRefreshToken = bestAccount.refreshToken;
4120+
let syncExpiresAt = bestAccount.expiresAt;
4121+
let syncIdToken: string | undefined;
4122+
4123+
if (!hasUsableAccessToken(bestAccount, switchNow)) {
4124+
const refreshResult = await queuedRefresh(bestAccount.refreshToken);
4125+
if (refreshResult.type === "success") {
4126+
const tokenAccountId = extractAccountId(refreshResult.access);
4127+
const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken));
4128+
if (bestAccount.refreshToken !== refreshResult.refresh) {
4129+
bestAccount.refreshToken = refreshResult.refresh;
4130+
}
4131+
if (bestAccount.accessToken !== refreshResult.access) {
4132+
bestAccount.accessToken = refreshResult.access;
4133+
}
4134+
if (bestAccount.expiresAt !== refreshResult.expires) {
4135+
bestAccount.expiresAt = refreshResult.expires;
4136+
}
4137+
if (nextEmail && nextEmail !== bestAccount.email) {
4138+
bestAccount.email = nextEmail;
4139+
}
4140+
if (tokenAccountId && tokenAccountId !== bestAccount.accountId) {
4141+
bestAccount.accountId = tokenAccountId;
4142+
bestAccount.accountIdSource = "token";
4143+
}
4144+
syncAccessToken = refreshResult.access;
4145+
syncRefreshToken = refreshResult.refresh;
4146+
syncExpiresAt = refreshResult.expires;
4147+
syncIdToken = refreshResult.idToken;
4148+
}
4149+
}
4150+
4151+
bestAccount.lastUsed = switchNow;
4152+
bestAccount.lastSwitchReason = "best";
4153+
await saveAccounts(storage);
4154+
4155+
const synced = await setCodexCliActiveSelection({
4156+
accountId: bestAccount.accountId,
4157+
email: bestAccount.email,
4158+
accessToken: syncAccessToken,
4159+
refreshToken: syncRefreshToken,
4160+
expiresAt: syncExpiresAt,
4161+
...(syncIdToken ? { idToken: syncIdToken } : {}),
4162+
});
4163+
4164+
if (options.json) {
4165+
console.log(JSON.stringify({
4166+
message: `Switched to best account: ${formatAccountLabel(bestAccount, targetIndex)}`,
4167+
accountIndex: parsed,
4168+
reason: recommendation.reason,
4169+
synced,
4170+
wasDisabled,
4171+
}, null, 2));
4172+
} else {
4173+
console.log(`Switched to best account ${parsed}: ${formatAccountLabel(bestAccount, targetIndex)}${wasDisabled ? " (re-enabled)" : ""}`);
4174+
console.log(`Reason: ${recommendation.reason}`);
4175+
if (!synced) {
4176+
console.warn("Codex auth sync did not complete. Multi-auth routing will still use this account.");
4177+
}
4178+
}
4179+
return 0;
4180+
}
4181+
39794182
export async function autoSyncActiveAccountToCodex(): Promise<boolean> {
39804183
setStoragePath(null);
39814184
const storage = await loadAccounts();
@@ -4095,6 +4298,9 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise<number> {
40954298
if (command === "forecast") {
40964299
return runForecast(rest);
40974300
}
4301+
if (command === "best") {
4302+
return runBest(rest);
4303+
}
40984304
if (command === "report") {
40994305
return runReport(rest);
41004306
}

lib/schemas.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export type CooldownReasonFromSchema = z.infer<typeof CooldownReasonSchema>;
8282
/**
8383
* Last switch reason for account rotation tracking.
8484
*/
85-
export const SwitchReasonSchema = z.enum(["rate-limit", "initial", "rotation"]);
85+
export const SwitchReasonSchema = z.enum(["rate-limit", "initial", "rotation", "best"]);
8686

8787
export type SwitchReasonFromSchema = z.infer<typeof SwitchReasonSchema>;
8888

lib/storage/migrations.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export interface AccountMetadataV1 {
2525
enabled?: boolean;
2626
addedAt: number;
2727
lastUsed: number;
28-
lastSwitchReason?: "rate-limit" | "initial" | "rotation";
28+
lastSwitchReason?: "rate-limit" | "initial" | "rotation" | "best";
2929
rateLimitResetTime?: number;
3030
coolingDownUntil?: number;
3131
cooldownReason?: CooldownReason;
@@ -50,7 +50,7 @@ export interface AccountMetadataV3 {
5050
enabled?: boolean;
5151
addedAt: number;
5252
lastUsed: number;
53-
lastSwitchReason?: "rate-limit" | "initial" | "rotation";
53+
lastSwitchReason?: "rate-limit" | "initial" | "rotation" | "best";
5454
rateLimitResetTimes?: RateLimitStateV3;
5555
coolingDownUntil?: number;
5656
cooldownReason?: CooldownReason;

scripts/codex-multi-auth.js

100644100755
File mode changed.

scripts/codex-routing.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const AUTH_SUBCOMMANDS = new Set([
33
"list",
44
"status",
55
"switch",
6+
"best",
67
"check",
78
"features",
89
"verify-flagged",

scripts/codex.js

100644100755
File mode changed.

0 commit comments

Comments
 (0)