pi-multi-account 1.3.0 → 1.4.0

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 (3) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/index.ts +77 -8
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.4.0] - 2026-06-10
9
+
10
+ ### Fixed
11
+
12
+ - **A single 401 no longer drops an account that still has valid tokens.** A 401 on
13
+ an OAuth account usually just means the access token needs a refresh (Pi refreshes
14
+ on the next call). Previously the first 401 permanently invalidated the account
15
+ (≈1-year cooldown until re-login) and yanked you onto another — often broken —
16
+ account. Now a refreshable account is given a brief cooldown and retried; it is
17
+ only marked dead after 3 consecutive 401s with no success in between. A
18
+ non-refreshable (API-key) 401 is still treated as immediately fatal.
19
+ - Any successful response clears that account's 401 streak.
20
+
21
+ ### Added
22
+
23
+ - Tests for transient-401 tolerance, the consecutive-401 kill threshold, and
24
+ success-resets-streak (suite now 17 tests).
25
+
8
26
  ## [1.3.0] - 2026-06-10
9
27
 
10
28
  ### Fixed
package/index.ts CHANGED
@@ -113,6 +113,16 @@ const DEFAULT_INVALID_COOLDOWN_MS = 365 * 24 * 60 * 60 * 1000; // effectively "u
113
113
  // until the machine swaps itself to death.
114
114
  const ANTI_PINGPONG_MS = 60 * 1000; // don't switch straight back to the account we just left
115
115
  const MIN_AUTOCONTINUE_INTERVAL_MS = 15 * 1000; // floor between auto-continuations (CPU/network guard)
116
+ // A 401 on an OAuth account usually just means "access token expired, refresh it" — Pi refreshes
117
+ // on the next call. So a single 401 must NOT permanently kill a refreshable account (that was the
118
+ // "it dropped me off an account that still had tokens" bug). Only kill it after this many 401s in a
119
+ // row with no successful response in between. Non-refreshable (API-key) 401 is fatal immediately.
120
+ // Bumped on every release. Printed at startup and in `/multi-account status` so you can verify
121
+ // which version Pi actually loaded (a running Pi keeps the version it started with — /login and
122
+ // /reload do NOT reload extension code; only a full restart does).
123
+ const VERSION = "1.4.0";
124
+ const MAX_CONSECUTIVE_AUTH_FAILURES = 3;
125
+ const TRANSIENT_AUTH_COOLDOWN_MS = 60 * 1000; // brief skip after a 401 so the next call can refresh
116
126
 
117
127
  const ANTHROPIC_BASE = "anthropic";
118
128
  const CODEX_BASE = "openai-codex";
@@ -800,6 +810,9 @@ export default function piMultiAccount(pi: ExtensionAPI) {
800
810
 
801
811
  const exhaustedUntilByProvider = new Map<string, number>(Object.entries(persistedState.exhaustedUntilByProvider ?? {}));
802
812
  const invalidatedByProvider = new Map<string, InvalidationRecord>(Object.entries(persistedState.invalidatedByProvider ?? {}));
813
+ // Consecutive 401s per provider (in-memory, reset on any success). Used so a transient 401 on a
814
+ // refreshable OAuth account doesn't permanently kill it.
815
+ const consecutiveAuthFailures = new Map<string, number>();
803
816
 
804
817
  // Discovered, authed, deduped provider ids in rotation order.
805
818
  let rotation: string[] = [];
@@ -902,6 +915,43 @@ export default function piMultiAccount(pi: ExtensionAPI) {
902
915
  return invalidatedByProvider.has(provider);
903
916
  }
904
917
 
918
+ /** True if this account can self-heal a 401 by refreshing its OAuth token. */
919
+ function isRefreshable(provider: string): boolean {
920
+ const entry = readAuthFile()[provider];
921
+ return !!entry && typeof entry.refresh === "string" && entry.refresh.length > 0;
922
+ }
923
+
924
+ /** A successful response → this account's auth is fine; clear its 401 streak. */
925
+ function noteAuthSuccess(provider: string) {
926
+ if (consecutiveAuthFailures.delete(provider)) {
927
+ /* had a streak, now cleared */
928
+ }
929
+ }
930
+
931
+ /**
932
+ * Handle a 401/auth failure WITHOUT nuking an account that just needs a token refresh.
933
+ * Refreshable (OAuth) account: first failures → short cooldown only, so the next attempt can
934
+ * refresh; only after MAX_CONSECUTIVE_AUTH_FAILURES in a row do we mark it dead-until-relogin.
935
+ * Non-refreshable (API key): a 401 is genuinely fatal, mark invalid at once.
936
+ * Returns true if the account was permanently invalidated.
937
+ */
938
+ function markAuthFailure(provider: string, reason: string): boolean {
939
+ if (!isRefreshable(provider)) {
940
+ markInvalid(provider, reason);
941
+ return true;
942
+ }
943
+ const n = (consecutiveAuthFailures.get(provider) ?? 0) + 1;
944
+ consecutiveAuthFailures.set(provider, n);
945
+ if (n >= MAX_CONSECUTIVE_AUTH_FAILURES) {
946
+ consecutiveAuthFailures.delete(provider);
947
+ markInvalid(provider, `${reason} (after ${n} consecutive 401s)`);
948
+ return true;
949
+ }
950
+ // Transient: brief cooldown so selection skips it for a moment; Pi refreshes on next use.
951
+ markExhausted(provider, TRANSIENT_AUTH_COOLDOWN_MS);
952
+ return false;
953
+ }
954
+
905
955
  // ----- cooldowns --------------------------------------------------------
906
956
 
907
957
  function pruneCooldowns() {
@@ -1269,6 +1319,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1269
1319
  autoContinuesThisPrompt = 0;
1270
1320
  userAbortedChain = false;
1271
1321
  userSelectedProvider = undefined;
1322
+ consecutiveAuthFailures.clear();
1272
1323
  if (pendingWakeTimer) {
1273
1324
  clearTimeout(pendingWakeTimer);
1274
1325
  pendingWakeTimer = undefined;
@@ -1299,7 +1350,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1299
1350
  const invalids = [...invalidatedByProvider.entries()].map(([p, r]) => `${p} (${r.reason.slice(0, 40)})`);
1300
1351
  ctx.ui.notify(
1301
1352
  [
1302
- `pi-multi-account: ${config.enabled ? "enabled" : "disabled"}${config.autoDiscover ? " · auto-discover ON" : " · auto-discover OFF"}`,
1353
+ `pi-multi-account v${VERSION}: ${config.enabled ? "enabled" : "disabled"}${config.autoDiscover ? " · auto-discover ON" : " · auto-discover OFF"}`,
1303
1354
  `Current: ${current}`,
1304
1355
  `Rotation (${rotation.length}): ${rotation.join(" → ") || "none — log in to an account"}`,
1305
1356
  `Registered login slots: ${[...registeredSlots].join(", ") || "(base accounts only)"}`,
@@ -1344,7 +1395,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1344
1395
  expectingSelfContinuation = false;
1345
1396
  lastSentContinuationPrompt = "";
1346
1397
  ctx.ui.notify(
1347
- `pi-multi-account loaded (${config.enabled ? "enabled" : "disabled"}). ${rotation.length} account(s) in rotation. Config: ${CONFIG_PATH}`,
1398
+ `pi-multi-account v${VERSION} loaded (${config.enabled ? "enabled" : "disabled"}). ${rotation.length} account(s) in rotation. Config: ${CONFIG_PATH}`,
1348
1399
  "info",
1349
1400
  );
1350
1401
  });
@@ -1410,11 +1461,23 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1410
1461
  if (!config.enabled) return;
1411
1462
  if (userAbortedChain || ctx.signal?.aborted) return; // user is cancelling — don't fail over
1412
1463
  const status = (event as any).status;
1413
- // The user's manually-picked model just worked resume normal auto-failover for it.
1414
- if (status < 400 && ctx.model && ctx.model.provider === userSelectedProvider) userSelectedProvider = undefined;
1464
+ // A successful response proves this account's auth is fineclear its 401 streak, and
1465
+ // release a manual pin so normal auto-failover resumes for the user's chosen model.
1466
+ if (status < 400 && ctx.model) {
1467
+ noteAuthSuccess(ctx.model.provider);
1468
+ if (ctx.model.provider === userSelectedProvider) userSelectedProvider = undefined;
1469
+ }
1415
1470
  if (status === 401) {
1416
- // Authorization is dead drop this account, then move on.
1417
- if (ctx.model) markInvalid(ctx.model.provider, `HTTP 401`);
1471
+ // A 401 on an OAuth account usually just needs a token refresh — do NOT kill it on the
1472
+ // first one. markAuthFailure only invalidates after repeated 401s; otherwise it's a
1473
+ // brief cooldown so Pi can refresh and we retry the SAME account, not abandon it.
1474
+ if (ctx.model) {
1475
+ const killed = markAuthFailure(ctx.model.provider, "HTTP 401");
1476
+ if (!killed) {
1477
+ ctx.ui.notify(`Provider failover: ${ctx.model.provider} got a 401 — will refresh and retry (not dropping it).`, "info");
1478
+ return; // give the same account a chance to refresh on the next call
1479
+ }
1480
+ }
1418
1481
  await switchToFallback(ctx, "HTTP 401 (auth invalid)");
1419
1482
  return;
1420
1483
  }
@@ -1432,7 +1495,13 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1432
1495
  lastErrorText = errorText;
1433
1496
  if (currentPromptSwitch) return;
1434
1497
  if (isAuthError(errorText)) {
1435
- if (ctx.model) markInvalid(ctx.model.provider, errorText.slice(0, 60));
1498
+ if (ctx.model) {
1499
+ const killed = markAuthFailure(ctx.model.provider, errorText.slice(0, 60));
1500
+ if (!killed) {
1501
+ ctx.ui.notify(`Provider failover: ${ctx.model.provider} hit a transient auth error — will refresh and retry (not dropping it).`, "info");
1502
+ return; // refreshable account: let it refresh and retry rather than abandoning it
1503
+ }
1504
+ }
1436
1505
  await switchToFallback(ctx, `auth invalid: ${errorText.slice(0, 100)}`);
1437
1506
  return;
1438
1507
  }
@@ -1462,7 +1531,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1462
1531
  if (userAbortedChain) return;
1463
1532
 
1464
1533
  const errorText = lastErrorText || getAssistantErrorText((event as any).messages ?? []);
1465
- if (isAuthError(errorText) && ctx.model) markInvalid(ctx.model.provider, errorText.slice(0, 60));
1534
+ if (isAuthError(errorText) && ctx.model) markAuthFailure(ctx.model.provider, errorText.slice(0, 60));
1466
1535
  if (!isLimitError(errorText) && !isAuthError(errorText)) return;
1467
1536
 
1468
1537
  // Task-level cap. Because this counter is no longer reset by our own re-prompts,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multi-account",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Automatic multi-account failover & rotation for Pi Agent across Anthropic (Claude), OpenAI/ChatGPT Codex, and Qwen/Alibaba. Auto-discovers authenticated accounts, grows the rotation on login, and drops accounts on logout, expiry, or quota/rate-limit errors.",
5
5
  "type": "module",
6
6
  "license": "MIT",