@juspay/neurolink 11.2.0 → 11.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/auth/codexOAuth.d.ts +67 -0
- package/dist/auth/codexOAuth.js +202 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.js +4 -0
- package/dist/browser/neurolink.min.js +419 -419
- package/dist/cli/commands/auth.d.ts +27 -8
- package/dist/cli/commands/auth.js +425 -6
- package/dist/cli/commands/proxy.js +230 -5
- package/dist/cli/factories/authCommandFactory.d.ts +8 -0
- package/dist/cli/factories/authCommandFactory.js +74 -1
- package/dist/lib/auth/codexOAuth.d.ts +67 -0
- package/dist/lib/auth/codexOAuth.js +203 -0
- package/dist/lib/auth/index.d.ts +1 -0
- package/dist/lib/auth/index.js +4 -0
- package/dist/lib/proxy/accountCooldown.js +35 -2
- package/dist/lib/proxy/accountQuota.d.ts +29 -3
- package/dist/lib/proxy/accountQuota.js +203 -12
- package/dist/lib/proxy/accountUsage.js +15 -2
- package/dist/lib/proxy/codexAccountUsage.d.ts +26 -0
- package/dist/lib/proxy/codexAccountUsage.js +174 -0
- package/dist/lib/proxy/proxyAnalysis.js +12 -1
- package/dist/lib/proxy/proxyConfig.js +24 -0
- package/dist/lib/proxy/routingEvidence.d.ts +12 -1
- package/dist/lib/proxy/routingEvidence.js +23 -0
- package/dist/lib/proxy/runtimeConfig.js +3 -0
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +79 -5
- package/dist/lib/server/routes/claudeProxyRoutes.js +653 -72
- package/dist/lib/server/routes/codexProxyRoutes.d.ts +64 -0
- package/dist/lib/server/routes/codexProxyRoutes.js +454 -0
- package/dist/lib/types/cli.d.ts +7 -1
- package/dist/lib/types/codex.d.ts +95 -0
- package/dist/lib/types/codex.js +15 -0
- package/dist/lib/types/index.d.ts +1 -0
- package/dist/lib/types/index.js +1 -0
- package/dist/lib/types/proxy.d.ts +83 -0
- package/dist/lib/types/subscription.d.ts +13 -0
- package/dist/proxy/accountCooldown.js +35 -2
- package/dist/proxy/accountQuota.d.ts +29 -3
- package/dist/proxy/accountQuota.js +203 -12
- package/dist/proxy/accountUsage.js +15 -2
- package/dist/proxy/codexAccountUsage.d.ts +26 -0
- package/dist/proxy/codexAccountUsage.js +173 -0
- package/dist/proxy/proxyAnalysis.js +12 -1
- package/dist/proxy/proxyConfig.js +24 -0
- package/dist/proxy/routingEvidence.d.ts +12 -1
- package/dist/proxy/routingEvidence.js +23 -0
- package/dist/proxy/runtimeConfig.js +3 -0
- package/dist/server/routes/claudeProxyRoutes.d.ts +79 -5
- package/dist/server/routes/claudeProxyRoutes.js +653 -72
- package/dist/server/routes/codexProxyRoutes.d.ts +64 -0
- package/dist/server/routes/codexProxyRoutes.js +453 -0
- package/dist/types/cli.d.ts +7 -1
- package/dist/types/codex.d.ts +95 -0
- package/dist/types/codex.js +14 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/proxy.d.ts +83 -0
- package/dist/types/subscription.d.ts +13 -0
- package/package.json +2 -1
|
@@ -26,6 +26,16 @@ import type { AccountQuota, AuthCommandArgs } from "../../lib/types/index.js";
|
|
|
26
26
|
* (e.g., "anthropic:alice") to support multi-account pools.
|
|
27
27
|
*/
|
|
28
28
|
export declare function handleLogin(argv: AuthCommandArgs): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Handle Codex (ChatGPT) login by importing the current credential from
|
|
31
|
+
* `~/.codex/auth.json` into the account pool under a `codex:<label>` key.
|
|
32
|
+
*
|
|
33
|
+
* To add multiple ChatGPT accounts: `codex login` as account A, then
|
|
34
|
+
* `neurolink auth login codex --label a`; repeat for account B, etc. The proxy
|
|
35
|
+
* then pools all imported accounts and rotates automatically — no manual
|
|
36
|
+
* account switching during use.
|
|
37
|
+
*/
|
|
38
|
+
export declare function handleCodexLogin(argv: AuthCommandArgs): Promise<void>;
|
|
29
39
|
/**
|
|
30
40
|
* Format the dynamic per-plan limit windows (model-scoped weeklies such as
|
|
31
41
|
* Fable, plus any future kinds) as extra display lines. `session` and
|
|
@@ -64,14 +74,6 @@ export declare function handleStatus(argv: AuthCommandArgs): Promise<void>;
|
|
|
64
74
|
* `neurolink auth refresh <provider>`
|
|
65
75
|
*/
|
|
66
76
|
export declare function handleRefresh(argv: AuthCommandArgs): Promise<void>;
|
|
67
|
-
/**
|
|
68
|
-
* Handle the cleanup subcommand
|
|
69
|
-
* `neurolink auth cleanup [--force]`
|
|
70
|
-
*
|
|
71
|
-
* Removes stale accounts from the token store:
|
|
72
|
-
* 1. Expired entries with no refresh token (via pruneExpired)
|
|
73
|
-
* 2. Permanently disabled entries (after confirmation)
|
|
74
|
-
*/
|
|
75
77
|
export declare function handleCleanup(argv: AuthCommandArgs): Promise<void>;
|
|
76
78
|
/**
|
|
77
79
|
* Handle the enable subcommand
|
|
@@ -80,6 +82,14 @@ export declare function handleCleanup(argv: AuthCommandArgs): Promise<void>;
|
|
|
80
82
|
* Re-enables a previously disabled account so it can be used by the proxy pool again.
|
|
81
83
|
*/
|
|
82
84
|
export declare function handleEnable(argv: AuthCommandArgs): Promise<void>;
|
|
85
|
+
export declare function handleDisable(argv: AuthCommandArgs): Promise<void>;
|
|
86
|
+
/**
|
|
87
|
+
* Inspect or clear the per-account rate-limit cooldowns the proxy persists.
|
|
88
|
+
*
|
|
89
|
+
* Without this there is no way to see why an account vanished from the pool, or
|
|
90
|
+
* to release one parked by a bad reset timestamp.
|
|
91
|
+
*/
|
|
92
|
+
export declare function handleCooldown(argv: AuthCommandArgs): Promise<void>;
|
|
83
93
|
/**
|
|
84
94
|
* Handle the set-primary subcommand
|
|
85
95
|
* `neurolink auth set-primary <email> [--config <path>]`
|
|
@@ -89,6 +99,15 @@ export declare function handleEnable(argv: AuthCommandArgs): Promise<void>;
|
|
|
89
99
|
* Does not touch the token store.
|
|
90
100
|
*/
|
|
91
101
|
export declare function handleSetPrimary(argv: AuthCommandArgs): Promise<void>;
|
|
102
|
+
/**
|
|
103
|
+
* Handle the overage subcommand
|
|
104
|
+
* `neurolink auth overage <status|auto|always|never> [--config <path>]`
|
|
105
|
+
*
|
|
106
|
+
* Controls whether the pool may keep serving on paid extra usage once an
|
|
107
|
+
* account's subscription window is spent. Only `never` overrides the provider:
|
|
108
|
+
* nothing here can switch on extra usage that Anthropic reports as disabled.
|
|
109
|
+
*/
|
|
110
|
+
export declare function handleOverage(argv: AuthCommandArgs): Promise<void>;
|
|
92
111
|
/**
|
|
93
112
|
* Handle the get-primary subcommand
|
|
94
113
|
* `neurolink auth get-primary [--config <path>]`
|
|
@@ -29,6 +29,8 @@ import { defaultTokenStore } from "../../lib/auth/tokenStore.js";
|
|
|
29
29
|
import { CLAUDE_CODE_CLIENT_ID, ANTHROPIC_AUTH_URL, ANTHROPIC_TOKEN_URL, ANTHROPIC_REDIRECT_URI, CLAUDE_CLI_USER_AGENT, OAUTH_BETA_HEADERS, } from "../../lib/auth/anthropicOAuth.js";
|
|
30
30
|
import { flushAccountQuotas, loadAccountQuotas, saveAccountQuota, } from "../../lib/proxy/accountQuota.js";
|
|
31
31
|
import { fetchAccountUsage, listAnthropicAccountsForUsage, usageToQuota, } from "../../lib/proxy/accountUsage.js";
|
|
32
|
+
import { importCodexAuthFile } from "../../lib/auth/codexOAuth.js";
|
|
33
|
+
import { fetchCodexAccountUsage, listCodexAccountsForUsage, } from "../../lib/proxy/codexAccountUsage.js";
|
|
32
34
|
// =============================================================================
|
|
33
35
|
// CONSTANTS
|
|
34
36
|
// =============================================================================
|
|
@@ -62,7 +64,7 @@ const ANTHROPIC_CONSOLE_OAUTH_CONFIG = {
|
|
|
62
64
|
createApiKeyUrl: "https://api.anthropic.com/api/oauth/claude_cli/create_api_key",
|
|
63
65
|
};
|
|
64
66
|
// Supported providers
|
|
65
|
-
const SUPPORTED_PROVIDERS = ["anthropic"];
|
|
67
|
+
const SUPPORTED_PROVIDERS = ["anthropic", "codex"];
|
|
66
68
|
// =============================================================================
|
|
67
69
|
// SUBCOMMAND HANDLERS
|
|
68
70
|
// =============================================================================
|
|
@@ -81,6 +83,12 @@ export async function handleLogin(argv) {
|
|
|
81
83
|
logger.error(chalk.red(`Unsupported provider: ${provider}. Supported: ${SUPPORTED_PROVIDERS.join(", ")}`));
|
|
82
84
|
process.exit(1);
|
|
83
85
|
}
|
|
86
|
+
// Codex has a dedicated import-based login path (imports the current
|
|
87
|
+
// ChatGPT credential from ~/.codex/auth.json into the pool).
|
|
88
|
+
if (provider === "codex") {
|
|
89
|
+
await handleCodexLogin(argv);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
84
92
|
// If method is specified, use it directly
|
|
85
93
|
// Each handler returns true when credentials were written to a file,
|
|
86
94
|
// false for .env-only or "keep existing" paths.
|
|
@@ -115,6 +123,92 @@ export async function handleLogin(argv) {
|
|
|
115
123
|
process.exit(1);
|
|
116
124
|
}
|
|
117
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Handle Codex (ChatGPT) login by importing the current credential from
|
|
128
|
+
* `~/.codex/auth.json` into the account pool under a `codex:<label>` key.
|
|
129
|
+
*
|
|
130
|
+
* To add multiple ChatGPT accounts: `codex login` as account A, then
|
|
131
|
+
* `neurolink auth login codex --label a`; repeat for account B, etc. The proxy
|
|
132
|
+
* then pools all imported accounts and rotates automatically — no manual
|
|
133
|
+
* account switching during use.
|
|
134
|
+
*/
|
|
135
|
+
export async function handleCodexLogin(argv) {
|
|
136
|
+
const spinner = ora("Importing Codex (ChatGPT) credential…").start();
|
|
137
|
+
try {
|
|
138
|
+
const credential = await importCodexAuthFile();
|
|
139
|
+
const rawLabel = argv.label ??
|
|
140
|
+
credential.email ??
|
|
141
|
+
credential.accountId?.slice(0, 8) ??
|
|
142
|
+
Date.now().toString(36).slice(-6);
|
|
143
|
+
const label = rawLabel.trim();
|
|
144
|
+
const compoundKey = `codex:${label}`;
|
|
145
|
+
const scopeParts = ["codex"];
|
|
146
|
+
if (credential.planType) {
|
|
147
|
+
scopeParts.push(`plan:${credential.planType}`);
|
|
148
|
+
}
|
|
149
|
+
if (credential.email) {
|
|
150
|
+
scopeParts.push(`email:${credential.email}`);
|
|
151
|
+
}
|
|
152
|
+
if (credential.accountId) {
|
|
153
|
+
scopeParts.push(`account:${credential.accountId}`);
|
|
154
|
+
}
|
|
155
|
+
await defaultTokenStore.saveTokens(compoundKey, {
|
|
156
|
+
accessToken: credential.accessToken,
|
|
157
|
+
refreshToken: credential.refreshToken,
|
|
158
|
+
expiresAt: credential.expiresAt ?? Date.now() + 3_600_000,
|
|
159
|
+
tokenType: "Bearer",
|
|
160
|
+
scope: scopeParts.join(" "),
|
|
161
|
+
});
|
|
162
|
+
await defaultTokenStore.markEnabled(compoundKey);
|
|
163
|
+
spinner.succeed(`Codex account added to pool: ${chalk.cyan(compoundKey)}`);
|
|
164
|
+
if (credential.planType) {
|
|
165
|
+
logger.always(chalk.gray(` plan: ${credential.planType}`));
|
|
166
|
+
}
|
|
167
|
+
if (credential.email) {
|
|
168
|
+
logger.always(chalk.gray(` email: ${credential.email}`));
|
|
169
|
+
}
|
|
170
|
+
logger.always(chalk.gray(" Add more accounts: `codex login` (new account) then `neurolink auth login codex --label <name>`"));
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
spinner.fail("Codex login failed");
|
|
174
|
+
logger.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Pool-state display helpers
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
/**
|
|
182
|
+
* One line per account explaining why the proxy is or isn't routing to it.
|
|
183
|
+
*
|
|
184
|
+
* The proxy skips accounts on two independent grounds — a persisted `disabled`
|
|
185
|
+
* flag and an active cooldown — and neither was previously visible from
|
|
186
|
+
* `auth list`, so an account could silently drop out of rotation.
|
|
187
|
+
*/
|
|
188
|
+
async function collectAccountPoolState(accountKeys) {
|
|
189
|
+
const notes = {};
|
|
190
|
+
try {
|
|
191
|
+
const { loadAccountCooldowns } = await import("../../lib/proxy/accountCooldown.js");
|
|
192
|
+
const cooldowns = await loadAccountCooldowns();
|
|
193
|
+
const now = Date.now();
|
|
194
|
+
for (const key of accountKeys) {
|
|
195
|
+
if (await defaultTokenStore.isDisabled(key)) {
|
|
196
|
+
const reason = (await defaultTokenStore.getDisabledReason(key)) ?? "";
|
|
197
|
+
notes[key] = chalk.red(`disabled${reason ? ` (${reason})` : ""} — re-enable: neurolink auth enable ${key}`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const cooldown = cooldowns[key];
|
|
201
|
+
if (cooldown && cooldown.coolingUntil > now) {
|
|
202
|
+
const minutes = Math.max(1, Math.round((cooldown.coolingUntil - now) / 60000));
|
|
203
|
+
notes[key] = chalk.yellow(`cooling (${cooldown.reason}) for ~${minutes}m`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// Pool state is advisory — never block the listing on it.
|
|
209
|
+
}
|
|
210
|
+
return notes;
|
|
211
|
+
}
|
|
118
212
|
// ---------------------------------------------------------------------------
|
|
119
213
|
// Quota display helpers
|
|
120
214
|
// ---------------------------------------------------------------------------
|
|
@@ -286,6 +380,39 @@ async function refreshAccountLimitsForList() {
|
|
|
286
380
|
// saves never reach disk.
|
|
287
381
|
await flushAccountQuotas().catch(() => undefined);
|
|
288
382
|
}
|
|
383
|
+
// Codex accounts: fetch the ChatGPT usage windows. Keyed by the full
|
|
384
|
+
// `codex:` account key so quota never collides with an anthropic account
|
|
385
|
+
// that shares a bare label.
|
|
386
|
+
try {
|
|
387
|
+
const codexAccounts = await listCodexAccountsForUsage();
|
|
388
|
+
for (const account of codexAccounts) {
|
|
389
|
+
if (account.type !== "oauth") {
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
const result = await fetchCodexAccountUsage(account);
|
|
394
|
+
if (!result.ok) {
|
|
395
|
+
errors.push(`${account.label}: codex usage ${result.reason}`);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
await saveAccountQuota(account.key, result.quota);
|
|
399
|
+
quotas[account.key] = result.quota;
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
errors.push(`${account.label}: ${err instanceof Error ? err.message : String(err)}`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
// Enumeration can throw before the per-account guard is reached. Without
|
|
408
|
+
// this the failure escapes to the outer handler and the Anthropic quotas
|
|
409
|
+
// fetched just above are dropped, reporting a total failure for a
|
|
410
|
+
// Codex-only problem.
|
|
411
|
+
errors.push(`codex accounts: ${err instanceof Error ? err.message : String(err)}`);
|
|
412
|
+
}
|
|
413
|
+
finally {
|
|
414
|
+
await flushAccountQuotas().catch(() => undefined);
|
|
415
|
+
}
|
|
289
416
|
return { via: "direct", quotas, errors };
|
|
290
417
|
}
|
|
291
418
|
catch (err) {
|
|
@@ -407,7 +534,7 @@ export async function handleList(argv) {
|
|
|
407
534
|
if (argv.format === "json") {
|
|
408
535
|
// Merge quota data into each account object for JSON output
|
|
409
536
|
const withQuota = enrichedAccounts.map((acct) => {
|
|
410
|
-
const quotaKey = acct.label ?? acct.key;
|
|
537
|
+
const quotaKey = acct.provider === "codex" ? acct.key : (acct.label ?? acct.key);
|
|
411
538
|
const quota = quotas[quotaKey] ?? null;
|
|
412
539
|
return { ...acct, quota };
|
|
413
540
|
});
|
|
@@ -437,10 +564,13 @@ export async function handleList(argv) {
|
|
|
437
564
|
logger.always(chalk.bold("\nAuthenticated Accounts:\n"));
|
|
438
565
|
// Check if any account has quota data to decide column layout
|
|
439
566
|
const hasQuota = enrichedAccounts.some((acct) => {
|
|
440
|
-
const quotaKey = acct.label ?? acct.key;
|
|
567
|
+
const quotaKey = acct.provider === "codex" ? acct.key : (acct.label ?? acct.key);
|
|
441
568
|
return quotas[quotaKey] !== undefined;
|
|
442
569
|
});
|
|
443
570
|
// Table header
|
|
571
|
+
// Why an account is (or isn't) in the proxy pool. Without this a
|
|
572
|
+
// disabled or parked account simply vanishes from routing with no clue.
|
|
573
|
+
const poolState = await collectAccountPoolState(enrichedAccounts.map((acct) => acct.key));
|
|
444
574
|
const colKey = "LABEL".padEnd(20);
|
|
445
575
|
const colEmail = "EMAIL".padEnd(28);
|
|
446
576
|
const colStatus = "TOKEN STATUS".padEnd(14);
|
|
@@ -463,8 +593,9 @@ export async function handleList(argv) {
|
|
|
463
593
|
else {
|
|
464
594
|
statusText = chalk.yellow("unknown".padEnd(14));
|
|
465
595
|
}
|
|
466
|
-
const quotaKey = acct.label ?? acct.key;
|
|
596
|
+
const quotaKey = acct.provider === "codex" ? acct.key : (acct.label ?? acct.key);
|
|
467
597
|
const quota = quotas[quotaKey];
|
|
598
|
+
const poolNote = poolState[acct.key];
|
|
468
599
|
if (hasQuota && quota) {
|
|
469
600
|
const qc = formatQuotaColumns(quota);
|
|
470
601
|
logger.always(` ${chalk.cyan(displayLabel)} ${displayProvider} ${displayEmail} ${statusText} ${qc.sessionText.padEnd(10)} ${qc.weeklyText.padEnd(10)}`);
|
|
@@ -477,12 +608,18 @@ export async function handleList(argv) {
|
|
|
477
608
|
for (const windowRow of formatQuotaWindowRows(quota)) {
|
|
478
609
|
logger.always(`${indent}${windowRow}`);
|
|
479
610
|
}
|
|
611
|
+
if (poolNote) {
|
|
612
|
+
logger.always(`${indent}${poolNote}`);
|
|
613
|
+
}
|
|
480
614
|
}
|
|
481
615
|
else {
|
|
482
616
|
const apiKeyNote = refreshOutcome && acct.tokenType && acct.tokenType !== "Bearer"
|
|
483
617
|
? chalk.gray(" (api key — not refreshed)")
|
|
484
618
|
: "";
|
|
485
619
|
logger.always(` ${chalk.cyan(displayLabel)} ${displayProvider} ${displayEmail} ${statusText}${hasQuota ? " - -" : ""}${apiKeyNote}`);
|
|
620
|
+
if (poolNote) {
|
|
621
|
+
logger.always(` ${poolNote}`);
|
|
622
|
+
}
|
|
486
623
|
}
|
|
487
624
|
}
|
|
488
625
|
logger.always("");
|
|
@@ -702,6 +839,51 @@ export async function handleStatus(argv) {
|
|
|
702
839
|
process.exit(1);
|
|
703
840
|
}
|
|
704
841
|
}
|
|
842
|
+
/**
|
|
843
|
+
* Refresh every pooled Codex account in place.
|
|
844
|
+
*
|
|
845
|
+
* Unlike the Anthropic path there is no single credentials file to rewrite:
|
|
846
|
+
* accounts live under `codex:<label>` in the token store, so each is refreshed
|
|
847
|
+
* and saved back individually. A failure on one account never stops the rest.
|
|
848
|
+
*/
|
|
849
|
+
async function handleCodexRefresh(argv) {
|
|
850
|
+
const { refreshCodexToken } = await import("../../lib/auth/codexOAuth.js");
|
|
851
|
+
const keys = await defaultTokenStore.listByPrefix("codex:");
|
|
852
|
+
if (keys.length === 0) {
|
|
853
|
+
logger.error(chalk.red("No Codex accounts found. Run 'codex login', then 'neurolink auth login codex --label <name>'."));
|
|
854
|
+
process.exit(1);
|
|
855
|
+
}
|
|
856
|
+
logger.always(chalk.blue(`\nRefreshing ${keys.length} Codex account(s)...\n`));
|
|
857
|
+
let failed = 0;
|
|
858
|
+
for (const key of keys) {
|
|
859
|
+
const label = key.slice("codex:".length) || key;
|
|
860
|
+
const spinner = argv.quiet ? null : ora(`${label}`).start();
|
|
861
|
+
try {
|
|
862
|
+
const tokens = await defaultTokenStore.peekTokens(key);
|
|
863
|
+
if (!tokens?.refreshToken) {
|
|
864
|
+
throw new Error("no refresh token stored");
|
|
865
|
+
}
|
|
866
|
+
const refreshed = await refreshCodexToken(tokens.refreshToken);
|
|
867
|
+
await defaultTokenStore.saveTokens(key, {
|
|
868
|
+
accessToken: refreshed.accessToken,
|
|
869
|
+
refreshToken: refreshed.refreshToken ?? tokens.refreshToken,
|
|
870
|
+
expiresAt: refreshed.expiresAt ?? Date.now() + 3_600_000,
|
|
871
|
+
tokenType: "Bearer",
|
|
872
|
+
...(tokens.scope ? { scope: tokens.scope } : {}),
|
|
873
|
+
});
|
|
874
|
+
spinner?.succeed(`${label} refreshed`);
|
|
875
|
+
}
|
|
876
|
+
catch (error) {
|
|
877
|
+
failed += 1;
|
|
878
|
+
spinner?.fail(`${label}: ${error instanceof Error ? error.message : "refresh failed"}`);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
if (failed > 0) {
|
|
882
|
+
logger.always(chalk.yellow(`\n${failed} account(s) need re-import: codex login, then neurolink auth login codex --label <name>\n`));
|
|
883
|
+
process.exit(1);
|
|
884
|
+
}
|
|
885
|
+
logger.always(chalk.green("\nAll Codex accounts refreshed.\n"));
|
|
886
|
+
}
|
|
705
887
|
/**
|
|
706
888
|
* Handle the refresh subcommand
|
|
707
889
|
* `neurolink auth refresh <provider>`
|
|
@@ -714,6 +896,12 @@ export async function handleRefresh(argv) {
|
|
|
714
896
|
logger.error(chalk.red(`Unsupported provider: ${provider}. Supported: ${SUPPORTED_PROVIDERS.join(", ")}`));
|
|
715
897
|
process.exit(1);
|
|
716
898
|
}
|
|
899
|
+
// Codex credentials live only in the pooled token store — there is no
|
|
900
|
+
// `codex-credentials.json` for the shared path below to read.
|
|
901
|
+
if (provider === "codex") {
|
|
902
|
+
await handleCodexRefresh(argv);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
717
905
|
logger.always(chalk.blue(`\nRefreshing ${provider} OAuth tokens...\n`));
|
|
718
906
|
const spinner = argv.quiet ? null : ora("Reading stored tokens...").start();
|
|
719
907
|
try {
|
|
@@ -828,6 +1016,22 @@ export async function handleRefresh(argv) {
|
|
|
828
1016
|
* 1. Expired entries with no refresh token (via pruneExpired)
|
|
829
1017
|
* 2. Permanently disabled entries (after confirmation)
|
|
830
1018
|
*/
|
|
1019
|
+
/**
|
|
1020
|
+
* Disable reasons whose credential is still valid — the account is out of the
|
|
1021
|
+
* pool because of an external condition, not because the login broke. Cleanup
|
|
1022
|
+
* must not delete these.
|
|
1023
|
+
*
|
|
1024
|
+
* Expressed as the set of reasons that DO mean the credential is broken, so an
|
|
1025
|
+
* unrecognised reason — notably the free text an operator passes to
|
|
1026
|
+
* `auth disable --reason` — is retained rather than deleted. The inverse
|
|
1027
|
+
* allowlist fails open: any reason not enumerated would silently become a
|
|
1028
|
+
* delete.
|
|
1029
|
+
*/
|
|
1030
|
+
const BROKEN_CREDENTIAL_DISABLE_REASONS = new Set([
|
|
1031
|
+
"missing_refresh_token",
|
|
1032
|
+
"refresh_invalid",
|
|
1033
|
+
"refresh_failed",
|
|
1034
|
+
]);
|
|
831
1035
|
export async function handleCleanup(argv) {
|
|
832
1036
|
try {
|
|
833
1037
|
const removed = [];
|
|
@@ -839,7 +1043,29 @@ export async function handleCleanup(argv) {
|
|
|
839
1043
|
// Step 2: Find disabled entries (pruneExpired already removes disabled
|
|
840
1044
|
// entries, but in case the user runs cleanup with entries that were
|
|
841
1045
|
// disabled between the prune call and now, check again)
|
|
842
|
-
const
|
|
1046
|
+
const allDisabledKeys = await defaultTokenStore.listDisabled();
|
|
1047
|
+
// Only credentials the proxy itself gave up on are deletable. A disable that
|
|
1048
|
+
// describes a condition outside the credential — an organization policy, or
|
|
1049
|
+
// an operator taking the account out of rotation — leaves a perfectly good
|
|
1050
|
+
// login that deleting would destroy for a condition someone else can revert.
|
|
1051
|
+
const disabledKeys = [];
|
|
1052
|
+
const retained = [];
|
|
1053
|
+
for (const key of allDisabledKeys) {
|
|
1054
|
+
const reason = (await defaultTokenStore.getDisabledReason(key)) ?? "";
|
|
1055
|
+
if (!BROKEN_CREDENTIAL_DISABLE_REASONS.has(reason)) {
|
|
1056
|
+
retained.push({ key, reason: reason || "unspecified" });
|
|
1057
|
+
}
|
|
1058
|
+
else {
|
|
1059
|
+
disabledKeys.push(key);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
if (retained.length > 0) {
|
|
1063
|
+
logger.always(chalk.blue(`\nKeeping ${retained.length} recoverable disabled account(s):`));
|
|
1064
|
+
for (const entry of retained) {
|
|
1065
|
+
logger.always(` - ${entry.key} (${entry.reason})`);
|
|
1066
|
+
}
|
|
1067
|
+
logger.always(chalk.gray(" Credentials are still valid. Use 'neurolink auth enable <account>' to restore, or 'auth remove' to delete."));
|
|
1068
|
+
}
|
|
843
1069
|
if (disabledKeys.length > 0) {
|
|
844
1070
|
let shouldRemove = false;
|
|
845
1071
|
if (argv.force || argv.nonInteractive) {
|
|
@@ -862,8 +1088,9 @@ export async function handleCleanup(argv) {
|
|
|
862
1088
|
}
|
|
863
1089
|
if (shouldRemove) {
|
|
864
1090
|
for (const key of disabledKeys) {
|
|
1091
|
+
const reason = (await defaultTokenStore.getDisabledReason(key)) ?? "unspecified";
|
|
865
1092
|
await defaultTokenStore.clearTokens(key);
|
|
866
|
-
removed.push({ key, reason:
|
|
1093
|
+
removed.push({ key, reason: `disabled: ${reason}` });
|
|
867
1094
|
}
|
|
868
1095
|
}
|
|
869
1096
|
}
|
|
@@ -917,6 +1144,115 @@ export async function handleEnable(argv) {
|
|
|
917
1144
|
process.exit(1);
|
|
918
1145
|
}
|
|
919
1146
|
}
|
|
1147
|
+
/**
|
|
1148
|
+
* The proxy re-reads the token store on every request, so a disable takes
|
|
1149
|
+
* effect on the next request with no restart.
|
|
1150
|
+
*
|
|
1151
|
+
* "refresh_failed" is refused deliberately: the proxy treats that exact reason
|
|
1152
|
+
* as a legacy entry and re-enables it automatically, which would silently undo
|
|
1153
|
+
* an operator's disable.
|
|
1154
|
+
*/
|
|
1155
|
+
const RESERVED_DISABLE_REASON = "refresh_failed";
|
|
1156
|
+
export async function handleDisable(argv) {
|
|
1157
|
+
try {
|
|
1158
|
+
const accountKey = argv.account || (argv._ && argv._[2] ? String(argv._[2]) : undefined);
|
|
1159
|
+
if (!accountKey) {
|
|
1160
|
+
logger.error(chalk.red("Missing required argument: <account>"));
|
|
1161
|
+
logger.always(chalk.blue("\nUsage: neurolink auth disable <account>\n" +
|
|
1162
|
+
"Run 'neurolink auth list' to see all accounts.\n"));
|
|
1163
|
+
process.exit(1);
|
|
1164
|
+
}
|
|
1165
|
+
const allKeys = await defaultTokenStore.listProviders();
|
|
1166
|
+
if (!allKeys.includes(accountKey)) {
|
|
1167
|
+
logger.error(chalk.red(`Account not found: ${accountKey}`));
|
|
1168
|
+
logger.always(chalk.blue("\nRun 'neurolink auth list' to see all authenticated accounts.\n"));
|
|
1169
|
+
process.exit(1);
|
|
1170
|
+
}
|
|
1171
|
+
const reason = argv.reason?.trim() || "manual";
|
|
1172
|
+
if (reason === RESERVED_DISABLE_REASON) {
|
|
1173
|
+
logger.error(chalk.red(`Reason "${RESERVED_DISABLE_REASON}" is reserved — the proxy re-enables accounts carrying it. Choose another reason.`));
|
|
1174
|
+
process.exit(1);
|
|
1175
|
+
}
|
|
1176
|
+
await defaultTokenStore.markDisabled(accountKey, reason);
|
|
1177
|
+
logger.always(chalk.yellow(`\nDisabled account: ${accountKey} (${reason})`));
|
|
1178
|
+
logger.always(chalk.gray(`Credentials are kept. Re-enable with: neurolink auth enable ${accountKey}\n`));
|
|
1179
|
+
}
|
|
1180
|
+
catch (error) {
|
|
1181
|
+
logger.error(chalk.red("Failed to disable account:"));
|
|
1182
|
+
logger.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
|
|
1183
|
+
process.exit(1);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
/** Compact "in 3h 12m" / "5m" rendering for a future timestamp. */
|
|
1187
|
+
function formatDuration(ms) {
|
|
1188
|
+
const totalMinutes = Math.max(0, Math.round(ms / 60000));
|
|
1189
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
1190
|
+
const minutes = totalMinutes % 60;
|
|
1191
|
+
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Inspect or clear the per-account rate-limit cooldowns the proxy persists.
|
|
1195
|
+
*
|
|
1196
|
+
* Without this there is no way to see why an account vanished from the pool, or
|
|
1197
|
+
* to release one parked by a bad reset timestamp.
|
|
1198
|
+
*/
|
|
1199
|
+
export async function handleCooldown(argv) {
|
|
1200
|
+
const { loadAccountCooldowns, clearAccountCooldown } = await import("../../lib/proxy/accountCooldown.js");
|
|
1201
|
+
const action = String(argv.action ?? "list");
|
|
1202
|
+
try {
|
|
1203
|
+
if (action !== "list" && action !== "clear") {
|
|
1204
|
+
logger.error(chalk.red(`Unknown action: ${action}. Use list|clear.`));
|
|
1205
|
+
process.exit(1);
|
|
1206
|
+
}
|
|
1207
|
+
const cooldowns = await loadAccountCooldowns();
|
|
1208
|
+
const now = Date.now();
|
|
1209
|
+
if (action === "list") {
|
|
1210
|
+
const active = Object.entries(cooldowns).filter(([, entry]) => entry.coolingUntil > now);
|
|
1211
|
+
if (argv.format === "json") {
|
|
1212
|
+
logger.always(JSON.stringify(active.map(([account, entry]) => ({ account, ...entry })), null, 2));
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
if (active.length === 0) {
|
|
1216
|
+
logger.always(chalk.green("\nNo accounts are cooling.\n"));
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
logger.always(chalk.bold("\nCooling accounts:\n"));
|
|
1220
|
+
for (const [account, entry] of active) {
|
|
1221
|
+
logger.always(` ${chalk.cyan(account.padEnd(32))} ${chalk.yellow(entry.reason.padEnd(10))} ${chalk.gray(`recovers in ${formatDuration(entry.coolingUntil - now)}`)}`);
|
|
1222
|
+
}
|
|
1223
|
+
logger.always("");
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
const targets = argv.all
|
|
1227
|
+
? Object.keys(cooldowns)
|
|
1228
|
+
: [argv.account || (argv._?.[3] ? String(argv._[3]) : "")].filter(Boolean);
|
|
1229
|
+
if (targets.length === 0) {
|
|
1230
|
+
logger.error(chalk.red("Specify an account key, or pass --all to clear every one."));
|
|
1231
|
+
process.exit(1);
|
|
1232
|
+
}
|
|
1233
|
+
// Cooldowns are keyed by the full compound key, so a bare label silently
|
|
1234
|
+
// matches nothing. Reporting success for it would send the operator away
|
|
1235
|
+
// believing an account was released when it is still parked.
|
|
1236
|
+
const unknown = targets.filter((target) => !(target in cooldowns));
|
|
1237
|
+
if (unknown.length > 0) {
|
|
1238
|
+
logger.error(chalk.red(`No cooldown found for: ${unknown.join(", ")}`));
|
|
1239
|
+
logger.always(chalk.blue("\nRun 'neurolink auth cooldown list' to see the exact account keys.\n"));
|
|
1240
|
+
process.exit(1);
|
|
1241
|
+
}
|
|
1242
|
+
for (const target of targets) {
|
|
1243
|
+
await clearAccountCooldown(target);
|
|
1244
|
+
}
|
|
1245
|
+
logger.always(chalk.green(`\nCleared ${targets.length} cooldown(s).`));
|
|
1246
|
+
// The running worker keeps its own copy of the cooldown map, loaded once at
|
|
1247
|
+
// startup, so a file-level clear alone would not release the account.
|
|
1248
|
+
logger.always(chalk.gray("Restart the proxy for a running instance to pick this up: neurolink proxy start\n"));
|
|
1249
|
+
}
|
|
1250
|
+
catch (error) {
|
|
1251
|
+
logger.error(chalk.red("Failed to read or clear cooldowns:"));
|
|
1252
|
+
logger.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
|
|
1253
|
+
process.exit(1);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
920
1256
|
// =============================================================================
|
|
921
1257
|
// PRIMARY ACCOUNT (proxy routing.primaryAccount in YAML)
|
|
922
1258
|
// =============================================================================
|
|
@@ -1118,6 +1454,72 @@ export async function handleSetPrimary(argv) {
|
|
|
1118
1454
|
process.exit(1);
|
|
1119
1455
|
}
|
|
1120
1456
|
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Handle the overage subcommand
|
|
1459
|
+
* `neurolink auth overage <status|auto|always|never> [--config <path>]`
|
|
1460
|
+
*
|
|
1461
|
+
* Controls whether the pool may keep serving on paid extra usage once an
|
|
1462
|
+
* account's subscription window is spent. Only `never` overrides the provider:
|
|
1463
|
+
* nothing here can switch on extra usage that Anthropic reports as disabled.
|
|
1464
|
+
*/
|
|
1465
|
+
export async function handleOverage(argv) {
|
|
1466
|
+
const action = String(argv.action ?? (argv._ && argv._[2] ? String(argv._[2]) : "status"));
|
|
1467
|
+
const filePath = argv.config ?? DEFAULT_PROXY_CONFIG_PATH;
|
|
1468
|
+
try {
|
|
1469
|
+
const doc = await readProxyConfigFile(filePath);
|
|
1470
|
+
const routing = getRoutingObject(doc.data);
|
|
1471
|
+
const current = (typeof routing["use-overage"] === "string"
|
|
1472
|
+
? routing["use-overage"]
|
|
1473
|
+
: typeof routing.useOverage === "string"
|
|
1474
|
+
? routing.useOverage
|
|
1475
|
+
: undefined) ?? "auto";
|
|
1476
|
+
if (action === "status") {
|
|
1477
|
+
logger.always(chalk.bold(`\nExtra-usage policy: ${chalk.cyan(current)}`));
|
|
1478
|
+
logger.always(chalk.gray(" auto — follow whatever Anthropic reports per account (default)\n" +
|
|
1479
|
+
" always — keep serving whenever the provider permits extra usage\n" +
|
|
1480
|
+
" never — stop at the subscription limit, never spend credits"));
|
|
1481
|
+
// The provider's own signal is what actually decides, so show it too —
|
|
1482
|
+
// a policy of "always" is inert when the organization has it switched off.
|
|
1483
|
+
const quotas = await loadAccountQuotas();
|
|
1484
|
+
const rows = Object.entries(quotas).filter(([account, quota]) =>
|
|
1485
|
+
// Anthropic only: Codex has no extra-usage concept and stores a
|
|
1486
|
+
// neutral "rejected", which would read here as a real restriction.
|
|
1487
|
+
!account.startsWith("codex:") &&
|
|
1488
|
+
(quota.overageStatus || quota.overageDisabledReason));
|
|
1489
|
+
if (rows.length > 0) {
|
|
1490
|
+
logger.always(chalk.bold("\nProvider extra-usage state:"));
|
|
1491
|
+
for (const [account, quota] of rows) {
|
|
1492
|
+
const enabled = quota.overageEnabled === true ||
|
|
1493
|
+
quota.overageStatus?.trim().toLowerCase() === "allowed";
|
|
1494
|
+
const detail = quota.overageDisabledReason
|
|
1495
|
+
? chalk.gray(` (${quota.overageDisabledReason})`)
|
|
1496
|
+
: "";
|
|
1497
|
+
logger.always(` ${chalk.cyan(account.padEnd(32))} ${enabled ? chalk.green("available") : chalk.yellow("unavailable")}${detail}`);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
logger.always("");
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
if (action !== "auto" && action !== "always" && action !== "never") {
|
|
1504
|
+
logger.error(chalk.red(`Unknown action: ${action}. Use status|auto|always|never.`));
|
|
1505
|
+
process.exit(1);
|
|
1506
|
+
}
|
|
1507
|
+
if (doc.hadComments) {
|
|
1508
|
+
logger.always(chalk.yellow(`⚠ Note: existing YAML comments in ${filePath} will not be preserved.`));
|
|
1509
|
+
}
|
|
1510
|
+
delete routing.useOverage;
|
|
1511
|
+
routing["use-overage"] = action;
|
|
1512
|
+
await writeProxyConfigFile(filePath, doc);
|
|
1513
|
+
logger.always(chalk.green(`✓ Extra-usage policy → ${action}`));
|
|
1514
|
+
logger.always(chalk.green(`✓ Saved to ${filePath}`));
|
|
1515
|
+
reportRunningProxyConfigUpdate(filePath);
|
|
1516
|
+
}
|
|
1517
|
+
catch (err) {
|
|
1518
|
+
logger.error(chalk.red("Failed to read or update extra-usage policy:"));
|
|
1519
|
+
logger.error(chalk.red(err instanceof Error ? err.message : "Unknown error"));
|
|
1520
|
+
process.exit(1);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1121
1523
|
/**
|
|
1122
1524
|
* Handle the get-primary subcommand
|
|
1123
1525
|
* `neurolink auth get-primary [--config <path>]`
|
|
@@ -1863,6 +2265,23 @@ async function getAuthStatus(provider) {
|
|
|
1863
2265
|
isAuthenticated: false,
|
|
1864
2266
|
method: "none",
|
|
1865
2267
|
};
|
|
2268
|
+
// Codex never writes a credentials file — its accounts exist only as pooled
|
|
2269
|
+
// `codex:<label>` token-store entries, so checking the file alone reports a
|
|
2270
|
+
// fully authenticated pool as "Not Authenticated".
|
|
2271
|
+
if (provider === "codex") {
|
|
2272
|
+
const keys = await defaultTokenStore.listByPrefix("codex:");
|
|
2273
|
+
if (keys.length > 0) {
|
|
2274
|
+
result.isAuthenticated = true;
|
|
2275
|
+
result.method = "oauth";
|
|
2276
|
+
const tokens = await defaultTokenStore.peekTokens(keys[0]);
|
|
2277
|
+
result.hasRefreshToken = !!tokens?.refreshToken;
|
|
2278
|
+
if (tokens?.expiresAt) {
|
|
2279
|
+
result.tokenExpiry = new Date(tokens.expiresAt).toLocaleString();
|
|
2280
|
+
result.needsRefresh = Date.now() >= tokens.expiresAt;
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
return result;
|
|
2284
|
+
}
|
|
1866
2285
|
// Check stored credentials FIRST (OAuth takes priority over API key)
|
|
1867
2286
|
const stored = await getStoredCredentials(provider);
|
|
1868
2287
|
if (stored) {
|