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.
- package/CHANGELOG.md +18 -0
- package/index.ts +77 -8
- 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
|
-
//
|
|
1414
|
-
|
|
1464
|
+
// A successful response proves this account's auth is fine → clear 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
|
-
//
|
|
1417
|
-
|
|
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)
|
|
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)
|
|
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
|
+
"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",
|