@juspay/neurolink 11.2.0 → 11.2.2

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/auth/codexOAuth.d.ts +67 -0
  3. package/dist/auth/codexOAuth.js +202 -0
  4. package/dist/auth/index.d.ts +1 -0
  5. package/dist/auth/index.js +4 -0
  6. package/dist/browser/neurolink.min.js +419 -419
  7. package/dist/cli/commands/auth.d.ts +27 -8
  8. package/dist/cli/commands/auth.js +425 -6
  9. package/dist/cli/commands/proxy.js +230 -5
  10. package/dist/cli/factories/authCommandFactory.d.ts +8 -0
  11. package/dist/cli/factories/authCommandFactory.js +74 -1
  12. package/dist/lib/auth/codexOAuth.d.ts +67 -0
  13. package/dist/lib/auth/codexOAuth.js +203 -0
  14. package/dist/lib/auth/index.d.ts +1 -0
  15. package/dist/lib/auth/index.js +4 -0
  16. package/dist/lib/providers/openaiChatCompletionsBase.js +26 -14
  17. package/dist/lib/proxy/accountCooldown.js +35 -2
  18. package/dist/lib/proxy/accountQuota.d.ts +29 -3
  19. package/dist/lib/proxy/accountQuota.js +203 -12
  20. package/dist/lib/proxy/accountUsage.js +15 -2
  21. package/dist/lib/proxy/codexAccountUsage.d.ts +26 -0
  22. package/dist/lib/proxy/codexAccountUsage.js +174 -0
  23. package/dist/lib/proxy/proxyAnalysis.js +12 -1
  24. package/dist/lib/proxy/proxyConfig.js +24 -0
  25. package/dist/lib/proxy/routingEvidence.d.ts +12 -1
  26. package/dist/lib/proxy/routingEvidence.js +23 -0
  27. package/dist/lib/proxy/runtimeConfig.js +3 -0
  28. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +79 -5
  29. package/dist/lib/server/routes/claudeProxyRoutes.js +653 -72
  30. package/dist/lib/server/routes/codexProxyRoutes.d.ts +64 -0
  31. package/dist/lib/server/routes/codexProxyRoutes.js +454 -0
  32. package/dist/lib/types/cli.d.ts +7 -1
  33. package/dist/lib/types/codex.d.ts +95 -0
  34. package/dist/lib/types/codex.js +15 -0
  35. package/dist/lib/types/index.d.ts +1 -0
  36. package/dist/lib/types/index.js +1 -0
  37. package/dist/lib/types/proxy.d.ts +83 -0
  38. package/dist/lib/types/subscription.d.ts +13 -0
  39. package/dist/providers/openaiChatCompletionsBase.js +26 -14
  40. package/dist/proxy/accountCooldown.js +35 -2
  41. package/dist/proxy/accountQuota.d.ts +29 -3
  42. package/dist/proxy/accountQuota.js +203 -12
  43. package/dist/proxy/accountUsage.js +15 -2
  44. package/dist/proxy/codexAccountUsage.d.ts +26 -0
  45. package/dist/proxy/codexAccountUsage.js +173 -0
  46. package/dist/proxy/proxyAnalysis.js +12 -1
  47. package/dist/proxy/proxyConfig.js +24 -0
  48. package/dist/proxy/routingEvidence.d.ts +12 -1
  49. package/dist/proxy/routingEvidence.js +23 -0
  50. package/dist/proxy/runtimeConfig.js +3 -0
  51. package/dist/server/routes/claudeProxyRoutes.d.ts +79 -5
  52. package/dist/server/routes/claudeProxyRoutes.js +653 -72
  53. package/dist/server/routes/codexProxyRoutes.d.ts +64 -0
  54. package/dist/server/routes/codexProxyRoutes.js +453 -0
  55. package/dist/types/cli.d.ts +7 -1
  56. package/dist/types/codex.d.ts +95 -0
  57. package/dist/types/codex.js +14 -0
  58. package/dist/types/index.d.ts +1 -0
  59. package/dist/types/index.js +1 -0
  60. package/dist/types/proxy.d.ts +83 -0
  61. package/dist/types/subscription.d.ts +13 -0
  62. package/package.json +3 -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 disabledKeys = await defaultTokenStore.listDisabled();
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: "disabled: refresh_failed" });
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) {