maxpool 1.5.23 → 1.5.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.5.23",
3
+ "version": "1.5.25",
4
4
  "description": "Multi-account Claude Code proxy with adaptive, rate-aware load balancing across Claude accounts",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -2276,6 +2276,11 @@ export class AccountManager {
2276
2276
  // for the bounded drain; the lease holder owns all rotation.
2277
2277
  if (!this.writerLease) return true;
2278
2278
 
2279
+ // A dead refresh token (invalid_grant) is PERMANENT until browser re-auth —
2280
+ // never auto-retry it. Without this the prober re-POSTs the rejected token every
2281
+ // ~60s forever (hammering Anthropic's OAuth endpoint). Cleared on re-login.
2282
+ if (account.refreshDead) return false;
2283
+
2279
2284
  if (!force && !isTokenExpiringSoon(account.expiresAt)) return true;
2280
2285
 
2281
2286
  // Coalesce concurrent refreshes
@@ -2309,6 +2314,11 @@ export class AccountManager {
2309
2314
  this.markTransientFailure(accountIndex, `token_refresh_${err.status || 'network'}`, { network: !err.status });
2310
2315
  } else {
2311
2316
  this.markAuthFailed(accountIndex, err.status || 401, 'token_refresh_failed');
2317
+ // The refresh TOKEN itself was rejected (invalid_grant) — permanent until
2318
+ // browser re-auth. Latch so ensureTokenFresh + the prober stop retrying a
2319
+ // dead token. Set ONLY here (a rejected refresh), never in markAuthFailed
2320
+ // (shared with provider auth failures).
2321
+ account.refreshDead = true;
2312
2322
  }
2313
2323
  return false;
2314
2324
  }
@@ -2350,6 +2360,7 @@ export class AccountManager {
2350
2360
  account.credential = accessToken;
2351
2361
  if (refreshToken) account.refreshToken = refreshToken;
2352
2362
  account.expiresAt = expiresAt;
2363
+ account.refreshDead = false; // fresh tokens from re-auth revive a dead-refresh account
2353
2364
  if (account.status === 'error') account.status = 'active';
2354
2365
  console.log(`[Maxpool] Updated tokens for account "${account.name}"`);
2355
2366
  this._onTokenRefresh?.(accountIndex, {
@@ -2434,6 +2445,7 @@ export class AccountManager {
2434
2445
  account.lastError = null;
2435
2446
  account.lastErrorAt = null;
2436
2447
  account.consecutiveFailures = 0;
2448
+ account.refreshDead = false;
2437
2449
  }
2438
2450
  return idx;
2439
2451
  }
@@ -2575,6 +2587,7 @@ export class AccountManager {
2575
2587
  priority: a.priority,
2576
2588
  runtime: a.runtime,
2577
2589
  status: a.status,
2590
+ refreshDead: Boolean(a.refreshDead),
2578
2591
  inFlight: a.inFlight,
2579
2592
  activeWeight: a.activeWeight,
2580
2593
  completedRequests: a.completedRequests,
package/src/prober.js CHANGED
@@ -59,7 +59,9 @@ export class Prober {
59
59
  this._running = true;
60
60
  this._inflight = (async () => {
61
61
  try {
62
- const oauth = this.am.accounts.filter(a => a.type === 'oauth' && a.credential);
62
+ // Skip auth-dead accounts (dead refresh token): probing them just re-POSTs
63
+ // the rejected token every cycle — a 400 storm. They recover only on re-auth.
64
+ const oauth = this.am.accounts.filter(a => a.type === 'oauth' && a.credential && !a.refreshDead);
63
65
  const providers = this.am.accounts.filter(a => a.type === 'provider' && a.credential);
64
66
  await Promise.all([
65
67
  ...oauth.map(a => this.probeOne(a)),
package/src/tui.js CHANGED
@@ -402,8 +402,8 @@ export class TUI {
402
402
  };
403
403
  } else if (k === 'l') {
404
404
  this._confirm(
405
- 'Log in via browser?',
406
- 'Opens a browser to add any Claude account; you name it afterward.',
405
+ 'Log in / re-authenticate via browser?',
406
+ 'Opens a browser. Logging into an account that\'s already added RE-AUTHENTICATES it in place (revives a reauth/error account) — no duplicate. Otherwise a new account is added.',
407
407
  () => this._doLogin(),
408
408
  );
409
409
  } else if (k === 'n' && this.am.accounts.length > 0) {
@@ -577,8 +577,10 @@ export class TUI {
577
577
  }
578
578
  }
579
579
 
580
- // Browser OAuth login: any Claude account, named afterward. Suspends the TUI
581
- // around the interactive flow (browser + name prompt), then resumes.
580
+ // Browser OAuth login — the SAME flow re-authenticates an existing account (matched
581
+ // by accountUuid) or adds a new one. When the login is for an account already added,
582
+ // we say so, keep its name, and skip the "name this account" prompt — so it's clear
583
+ // it's a RE-AUTH in place (revives a dead-refresh/reauth account), not a duplicate.
582
584
  async _doLogin() {
583
585
  const wasRunning = this.running;
584
586
  if (wasRunning) this.stop();
@@ -586,14 +588,26 @@ export class TUI {
586
588
  process.stdout.write('\nOpening browser to log into Claude…\n');
587
589
  const creds = await loginOAuth();
588
590
  const profile = await fetchProfile(creds.accessToken);
589
- const suggested = profile?.email
590
- || `account-${this.config.accounts.filter(a => a.name.startsWith('account-')).length + 1}`;
591
- const rl = createInterface({ input: process.stdin, output: process.stdout });
592
- const answer = await new Promise(resolve => rl.question(`Name this account [${suggested}]: `, resolve));
593
- rl.close();
594
- const name = String(answer || '').trim() || suggested;
595
- await this._upsertOAuthAccount({ creds, profile, name, source: 'login', verb: 'Added' });
596
- process.stdout.write(`\nAdded account "${name}". Returning to maxpool…\n`);
591
+ const existing = this._findExistingOAuthAccount(profile);
592
+ let name;
593
+ if (existing) {
594
+ process.stdout.write(`\nThis Claude account is already added as "${existing.name}" — re-authenticating it in place (no duplicate).\n`);
595
+ name = existing.name;
596
+ } else {
597
+ const suggested = profile?.email
598
+ || `account-${this.config.accounts.filter(a => a.name.startsWith('account-')).length + 1}`;
599
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
600
+ const answer = await new Promise(resolve => rl.question(`Name this NEW account [${suggested}]: `, resolve));
601
+ rl.close();
602
+ name = String(answer || '').trim() || suggested;
603
+ }
604
+ // Message honors the ACTUAL result: if a manually-typed name collided with a
605
+ // different existing account, upsert updates in place → "Re-authenticated", never
606
+ // a false "Added new account".
607
+ const { updated } = await this._upsertOAuthAccount({ creds, profile, name, source: 'login' });
608
+ process.stdout.write(updated
609
+ ? `\nRe-authenticated "${name}". Returning to maxpool…\n`
610
+ : `\nAdded new account "${name}". Returning to maxpool…\n`);
597
611
  } catch (e) {
598
612
  process.stdout.write(`\nLogin failed: ${e.message}\n`);
599
613
  } finally {
@@ -664,9 +678,14 @@ export class TUI {
664
678
  amAcct.expiresAt = creds.expiresAt;
665
679
  amAcct.accountUuid = entry.accountUuid;
666
680
  amAcct.name = name;
681
+ // Fresh tokens from re-auth revive a dead-refresh account. This in-place
682
+ // update is the actual re-login path (the index.js reconcile sees no change
683
+ // and never calls updateAccountTokens), so refreshDead MUST be cleared here.
684
+ amAcct.refreshDead = false;
667
685
  if (amAcct.status === 'error') amAcct.status = 'active';
668
686
  }
669
687
  this._addLog(`Updated account "${name}"`);
688
+ return { updated: true, name };
670
689
  } else {
671
690
  this.config.accounts.push(entry);
672
691
  try {
@@ -677,9 +696,21 @@ export class TUI {
677
696
  }
678
697
  this.am.addAccount(entry);
679
698
  this._addLog(`${verb} account "${name}"`);
699
+ return { updated: false, name };
680
700
  }
681
701
  }
682
702
 
703
+ // Find an already-configured OAuth account matching a fresh browser-login profile —
704
+ // by accountUuid (primary), then name===email — the SAME precedence _upsertOAuthAccount
705
+ // dedupes on, so the login flow can honestly say "re-authenticating" vs "adding new".
706
+ _findExistingOAuthAccount(profile) {
707
+ const uuid = profile?.accountUuid || null;
708
+ const email = profile?.email || null;
709
+ let acct = uuid ? this.config.accounts.find(a => a.type === 'oauth' && a.accountUuid === uuid) : null;
710
+ if (!acct && email) acct = this.config.accounts.find(a => a.type === 'oauth' && a.name === email);
711
+ return acct || null;
712
+ }
713
+
683
714
  async _doAddKey(apiKey) {
684
715
  const key = String(apiKey || '').trim();
685
716
  if (!key) { this._addLog('No API key entered'); return; }
@@ -975,8 +1006,13 @@ export class TUI {
975
1006
  if (a.enabled !== false && a.status === 'active' && this.am._isAccountWideRejected?.(a)) {
976
1007
  effectiveStatus = 'blocked';
977
1008
  }
1009
+ // A dead refresh token surfaces as "reauth" (re-login needed) rather than a
1010
+ // generic "error", so the user knows the fix. Display-only — account.status
1011
+ // stays 'error' so routing/eligibility still exclude it.
1012
+ if (a.enabled !== false && a.refreshDead) effectiveStatus = 'reauth';
978
1013
  switch (effectiveStatus) {
979
1014
  case 'active': status = isCur ? green('active') : 'active'; break;
1015
+ case 'reauth': status = yellow('reauth'); break;
980
1016
  case 'blocked': status = red('blocked'); break;
981
1017
  case 'probing': status = green('probing'); break;
982
1018
  case 'waiting': status = yellow('waiting'); break;
@@ -1111,7 +1147,7 @@ export class TUI {
1111
1147
  case 'normal':
1112
1148
  return ` ${bold('a')} Accounts ${bold('m')} Routing ${bold('s')} Sync ${bold('r')} Restart ${bold('q')} Stop`;
1113
1149
  case 'accounts':
1114
- return ` ${bold('l')} Login (browser) ${bold('k')} API key ${bold('n')} Rename ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
1150
+ return ` ${bold('l')} Login/re-auth (browser) ${bold('k')} API key ${bold('n')} Rename ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
1115
1151
  case 'routing':
1116
1152
  return ` ${bold('a')} Automatic ${bold('p')} Manual preference ${bold('f')} Cross-provider fallback ${bold('Esc')} Back`;
1117
1153
  case 'select': {