pi-multi-account 1.2.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 +41 -0
- package/index.ts +137 -115
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,46 @@ 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
|
+
|
|
26
|
+
## [1.3.0] - 2026-06-10
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **Manual model/account selection is now respected.** Picking a model (e.g. Opus
|
|
31
|
+
on another account) no longer gets auto-yanked onto a different provider on the
|
|
32
|
+
next rate limit — the failover stays put and tells you, until you switch with
|
|
33
|
+
`/model` or `/multi-account next`. The pin auto-releases after a successful
|
|
34
|
+
response on that provider.
|
|
35
|
+
- **No more self-resurrecting work.** All background resume timers were removed:
|
|
36
|
+
continuation now happens only synchronously inside an active turn, so Esc and
|
|
37
|
+
quitting always stop it. When every account is rate-limited the failover STOPS
|
|
38
|
+
and asks you to retry, instead of churning between exhausted accounts.
|
|
39
|
+
- **No more "Agent is already processing" / "Cannot continue from message role:
|
|
40
|
+
assistant".** Continuations are sent only when the agent is idle and not aborting.
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- Test suite (`npm test`) covering the failover edge cases: limit/401 failover,
|
|
45
|
+
all-accounts-exhausted stop, Esc/abort, manual-selection pinning, idle gating,
|
|
46
|
+
Anthropic OAuth shaping idempotency, and session shutdown. Wired into CI.
|
|
47
|
+
|
|
8
48
|
## [1.2.0] - 2026-06-10
|
|
9
49
|
|
|
10
50
|
### Added
|
|
@@ -76,6 +116,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
76
116
|
- Plaintext-free credential handling (SHA-256 fingerprints only); `0600`
|
|
77
117
|
config/state files.
|
|
78
118
|
|
|
119
|
+
[1.3.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.3.0
|
|
79
120
|
[1.2.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.2.0
|
|
80
121
|
[1.1.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.1.0
|
|
81
122
|
[1.0.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.0.0
|
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[] = [];
|
|
@@ -824,6 +837,11 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
824
837
|
let autoContinueTimer: ReturnType<typeof setTimeout> | undefined; // pending spaced continuation
|
|
825
838
|
let lastLeftProvider: string | undefined; // account we just failed away from (anti-ping-pong)
|
|
826
839
|
let lastLeftAt = 0;
|
|
840
|
+
// When the USER manually picks a model/account, we respect it: auto-failover will not yank
|
|
841
|
+
// them off it (that's the "I selected opus and it flipped to chatgpt" bug). selfModelSwitch
|
|
842
|
+
// marks our OWN setModel calls so the model_select event isn't mistaken for a manual pick.
|
|
843
|
+
let userSelectedProvider: string | undefined;
|
|
844
|
+
let selfModelSwitch = false;
|
|
827
845
|
// The thinking level the user intended for this turn. pi.setModel() re-clamps and
|
|
828
846
|
// persists the thinking level on every model switch, so without this it drifts
|
|
829
847
|
// downward across failovers ("thinking level keeps dropping"). We capture it before
|
|
@@ -897,6 +915,43 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
897
915
|
return invalidatedByProvider.has(provider);
|
|
898
916
|
}
|
|
899
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
|
+
|
|
900
955
|
// ----- cooldowns --------------------------------------------------------
|
|
901
956
|
|
|
902
957
|
function pruneCooldowns() {
|
|
@@ -1099,11 +1154,22 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1099
1154
|
return pool.sort((a, b) => a.remaining - b.remaining || a.rotIndex - b.rotIndex).map((s) => s.model);
|
|
1100
1155
|
}
|
|
1101
1156
|
|
|
1102
|
-
async function switchToFallback(ctx: any, reason: string, cooldownMs = config.cooldownMs) {
|
|
1157
|
+
async function switchToFallback(ctx: any, reason: string, cooldownMs = config.cooldownMs, manual = false) {
|
|
1103
1158
|
if (!config.enabled) return false;
|
|
1104
1159
|
const currentModel = ctx.model;
|
|
1105
1160
|
if (!currentModel) return false;
|
|
1106
1161
|
|
|
1162
|
+
// Respect a manual model choice: if the user just picked this provider, do NOT auto-yank
|
|
1163
|
+
// them onto another one — show the error and let them decide. Manual /multi-account next
|
|
1164
|
+
// bypasses this (manual=true).
|
|
1165
|
+
if (!manual && userSelectedProvider && currentModel.provider === userSelectedProvider) {
|
|
1166
|
+
ctx.ui.notify(
|
|
1167
|
+
`Provider failover: you selected ${currentModel.provider}/${currentModel.id} manually — staying on it (${reason.slice(0, 90)}). Use /model or /multi-account next to switch.`,
|
|
1168
|
+
"warning",
|
|
1169
|
+
);
|
|
1170
|
+
return false;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1107
1173
|
markExhausted(currentModel.provider, cooldownMs);
|
|
1108
1174
|
lastLeftProvider = currentModel.provider;
|
|
1109
1175
|
lastLeftAt = Date.now();
|
|
@@ -1127,7 +1193,9 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1127
1193
|
const from = ref(currentModel.provider, currentModel.id);
|
|
1128
1194
|
for (const fallback of candidates) {
|
|
1129
1195
|
const to = ref(fallback.provider, fallback.id);
|
|
1196
|
+
selfModelSwitch = true; // our own switch — not a manual user pick
|
|
1130
1197
|
const ok = await pi.setModel(fallback);
|
|
1198
|
+
selfModelSwitch = false;
|
|
1131
1199
|
if (!ok) {
|
|
1132
1200
|
// setModel failed → the account has no usable auth right now.
|
|
1133
1201
|
ctx.ui.notify(`Provider failover: ${to} has no usable auth, dropping from rotation`, "warning");
|
|
@@ -1152,35 +1220,31 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1152
1220
|
return config.continuationPrompt.replaceAll("{from}", record.from).replaceAll("{to}", record.to).replaceAll("{reason}", record.reason);
|
|
1153
1221
|
}
|
|
1154
1222
|
|
|
1155
|
-
/**
|
|
1156
|
-
|
|
1223
|
+
/**
|
|
1224
|
+
* Send our failover continuation — but ONLY when it is genuinely safe:
|
|
1225
|
+
* - the user has not aborted (Esc), and the current op isn't aborting, and
|
|
1226
|
+
* - the agent is idle (sending mid-turn throws "Agent is already processing" /
|
|
1227
|
+
* "Cannot continue from message role: assistant").
|
|
1228
|
+
* Returns whether it actually sent. No background timer is ever used, so a turn is
|
|
1229
|
+
* always active for Esc to cancel — Esc/quit therefore always stop the chain.
|
|
1230
|
+
*/
|
|
1231
|
+
function dispatchSelfContinuation(ctx: any, prompt: string): boolean {
|
|
1232
|
+
if (userAbortedChain || ctx.signal?.aborted || !ctx.isIdle()) return false;
|
|
1157
1233
|
lastAutoContinueAt = Date.now();
|
|
1158
1234
|
lastSentContinuationPrompt = prompt;
|
|
1159
1235
|
expectingSelfContinuation = true;
|
|
1160
|
-
pi.sendUserMessage(prompt
|
|
1236
|
+
pi.sendUserMessage(prompt);
|
|
1237
|
+
return true;
|
|
1161
1238
|
}
|
|
1162
1239
|
|
|
1163
1240
|
/**
|
|
1164
|
-
*
|
|
1165
|
-
*
|
|
1166
|
-
*
|
|
1241
|
+
* Continue after a successful failover switch — SYNCHRONOUSLY only.
|
|
1242
|
+
* Deliberately NOT a setTimeout: a deferred timer fires sendUserMessage when there is no
|
|
1243
|
+
* active turn for Esc to cancel, which is exactly how the chain escaped the user's control
|
|
1244
|
+
* and resurrected work on its own. Returns whether it sent.
|
|
1167
1245
|
*/
|
|
1168
|
-
function scheduleAutoContinue(ctx: any, prompt: string) {
|
|
1169
|
-
|
|
1170
|
-
clearTimeout(autoContinueTimer);
|
|
1171
|
-
autoContinueTimer = undefined;
|
|
1172
|
-
}
|
|
1173
|
-
const wait = Math.max(0, MIN_AUTOCONTINUE_INTERVAL_MS - (Date.now() - lastAutoContinueAt));
|
|
1174
|
-
if (wait === 0) {
|
|
1175
|
-
dispatchSelfContinuation(ctx, prompt);
|
|
1176
|
-
return;
|
|
1177
|
-
}
|
|
1178
|
-
autoContinueTimer = setTimeout(() => {
|
|
1179
|
-
autoContinueTimer = undefined;
|
|
1180
|
-
if (userAbortedChain || ctx.signal?.aborted) return; // user took over while we waited
|
|
1181
|
-
dispatchSelfContinuation(ctx, prompt);
|
|
1182
|
-
}, wait);
|
|
1183
|
-
ctx.ui.notify(`Provider failover: next auto-continue in ~${Math.ceil(wait / 1000)}s (press Esc to cancel).`, "info");
|
|
1246
|
+
function scheduleAutoContinue(ctx: any, prompt: string): boolean {
|
|
1247
|
+
return dispatchSelfContinuation(ctx, prompt);
|
|
1184
1248
|
}
|
|
1185
1249
|
|
|
1186
1250
|
function clearPendingContinuation() {
|
|
@@ -1192,96 +1256,21 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1192
1256
|
persist();
|
|
1193
1257
|
}
|
|
1194
1258
|
|
|
1195
|
-
function nextPendingWakeDelayMs() {
|
|
1196
|
-
if (!persistedState.pendingContinuationPrompt) return undefined;
|
|
1197
|
-
const now = Date.now();
|
|
1198
|
-
const lastProbe = lastProbeMap();
|
|
1199
|
-
let bestWakeAt = Number.POSITIVE_INFINITY;
|
|
1200
|
-
for (const provider of configuredProviders()) {
|
|
1201
|
-
if (isInvalidated(provider)) continue;
|
|
1202
|
-
const exhaustedUntil = exhaustedUntilByProvider.get(provider) ?? 0;
|
|
1203
|
-
if (exhaustedUntil <= now) return 1000;
|
|
1204
|
-
const probeDueAt = (lastProbe[provider] ?? 0) + config.probeCooldownMs;
|
|
1205
|
-
bestWakeAt = Math.min(bestWakeAt, exhaustedUntil, probeDueAt);
|
|
1206
|
-
}
|
|
1207
|
-
if (!Number.isFinite(bestWakeAt)) return config.probeCooldownMs;
|
|
1208
|
-
return Math.max(1000, Math.min(bestWakeAt - now, 2_147_483_647));
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
function schedulePendingWake(ctx?: any) {
|
|
1212
|
-
if (ctx) latestCtx = ctx;
|
|
1213
|
-
if (pendingWakeTimer) clearTimeout(pendingWakeTimer);
|
|
1214
|
-
const delayMs = nextPendingWakeDelayMs();
|
|
1215
|
-
if (delayMs === undefined) return;
|
|
1216
|
-
pendingWakeTimer = setTimeout(() => {
|
|
1217
|
-
pendingWakeTimer = undefined;
|
|
1218
|
-
void attemptPendingResume();
|
|
1219
|
-
}, delayMs);
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
1259
|
function setPendingContinuation(ctx: any, reason: string) {
|
|
1223
|
-
//
|
|
1224
|
-
//
|
|
1225
|
-
//
|
|
1226
|
-
|
|
1227
|
-
const
|
|
1228
|
-
|
|
1229
|
-
persistedState = {
|
|
1230
|
-
...persistedState,
|
|
1231
|
-
pendingContinuationPrompt: persistedState.pendingContinuationPrompt || continuationPrompt(record),
|
|
1232
|
-
pendingSince: persistedState.pendingSince || Date.now(),
|
|
1233
|
-
pendingReason: reason,
|
|
1234
|
-
};
|
|
1260
|
+
// Every available account is rate-limited or unavailable right now. We deliberately do
|
|
1261
|
+
// NOT arm a background timer to auto-resume later: such a timer fires sendUserMessage with
|
|
1262
|
+
// no active turn for Esc to cancel and resurrects work on its own. Instead we STOP cleanly
|
|
1263
|
+
// and tell the user — they retry by sending a message when an account has recovered.
|
|
1264
|
+
const alreadyStopped = persistedState.pendingReason === reason;
|
|
1265
|
+
persistedState = { ...persistedState, pendingReason: reason, pendingContinuationPrompt: undefined, pendingSince: undefined };
|
|
1235
1266
|
persist();
|
|
1236
|
-
|
|
1237
|
-
if (alreadyPending) return;
|
|
1238
|
-
const delayMs = nextPendingWakeDelayMs();
|
|
1267
|
+
if (alreadyStopped) return;
|
|
1239
1268
|
ctx.ui.notify(
|
|
1240
|
-
`Provider failover:
|
|
1269
|
+
`Provider failover: every account is rate-limited or unavailable right now — stopped here. Send a message to retry once one recovers (check /multi-account status).`,
|
|
1241
1270
|
"warning",
|
|
1242
1271
|
);
|
|
1243
1272
|
}
|
|
1244
1273
|
|
|
1245
|
-
async function attemptPendingResume() {
|
|
1246
|
-
const ctx = latestCtx;
|
|
1247
|
-
const prompt = persistedState.pendingContinuationPrompt;
|
|
1248
|
-
if (!ctx || !prompt || !config.enabled || !config.autoContinue) return;
|
|
1249
|
-
if (userAbortedChain) {
|
|
1250
|
-
clearPendingContinuation(); // user took over — abandon the background resurrection
|
|
1251
|
-
return;
|
|
1252
|
-
}
|
|
1253
|
-
if (autoContinuesThisPrompt >= config.maxAutoContinuesPerPrompt) {
|
|
1254
|
-
clearPendingContinuation(); // task-level cap reached — stop resurrecting
|
|
1255
|
-
return;
|
|
1256
|
-
}
|
|
1257
|
-
refreshDiscovery();
|
|
1258
|
-
pruneCooldowns();
|
|
1259
|
-
const candidates = findFallbackModels(ctx, ctx.model);
|
|
1260
|
-
if (candidates.length === 0) {
|
|
1261
|
-
schedulePendingWake(ctx);
|
|
1262
|
-
return;
|
|
1263
|
-
}
|
|
1264
|
-
for (const candidate of candidates) {
|
|
1265
|
-
const to = ref(candidate.provider, candidate.id);
|
|
1266
|
-
const ok = await pi.setModel(candidate);
|
|
1267
|
-
if (!ok) {
|
|
1268
|
-
markInvalid(candidate.provider, "setModel failed on resume");
|
|
1269
|
-
continue;
|
|
1270
|
-
}
|
|
1271
|
-
restoreDesiredThinking(); // keep the user's thinking level across the switch
|
|
1272
|
-
setLastProbe(candidate.provider);
|
|
1273
|
-
clearPendingContinuation();
|
|
1274
|
-
// A genuine recovery after a real wait earns a fresh continuation budget so the
|
|
1275
|
-
// agent can keep going whenever an account recovers; rapid flapping (resume that
|
|
1276
|
-
// immediately re-limits) does NOT reset, so the cap still bounds a tight loop.
|
|
1277
|
-
if (Date.now() - lastAutoContinueAt >= config.probeCooldownMs) autoContinuesThisPrompt = 0;
|
|
1278
|
-
ctx.ui.notify(`Provider failover: resuming pending work on ${to}`, "warning");
|
|
1279
|
-
dispatchSelfContinuation(ctx, prompt);
|
|
1280
|
-
return;
|
|
1281
|
-
}
|
|
1282
|
-
schedulePendingWake(ctx);
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
1274
|
// ----- error classification --------------------------------------------
|
|
1286
1275
|
|
|
1287
1276
|
function isAuthError(text: string) {
|
|
@@ -1328,6 +1317,9 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1328
1317
|
exhaustedUntilByProvider.clear();
|
|
1329
1318
|
currentPromptSwitch = undefined;
|
|
1330
1319
|
autoContinuesThisPrompt = 0;
|
|
1320
|
+
userAbortedChain = false;
|
|
1321
|
+
userSelectedProvider = undefined;
|
|
1322
|
+
consecutiveAuthFailures.clear();
|
|
1331
1323
|
if (pendingWakeTimer) {
|
|
1332
1324
|
clearTimeout(pendingWakeTimer);
|
|
1333
1325
|
pendingWakeTimer = undefined;
|
|
@@ -1340,7 +1332,8 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1340
1332
|
return;
|
|
1341
1333
|
}
|
|
1342
1334
|
if (command === "next") {
|
|
1343
|
-
|
|
1335
|
+
userSelectedProvider = undefined; // explicit request to move — drop any manual pin
|
|
1336
|
+
await switchToFallback(ctx, "manual /multi-account next", 5 * 60 * 1000, true);
|
|
1344
1337
|
return;
|
|
1345
1338
|
}
|
|
1346
1339
|
if (command === "enable" || command === "disable") {
|
|
@@ -1357,7 +1350,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1357
1350
|
const invalids = [...invalidatedByProvider.entries()].map(([p, r]) => `${p} (${r.reason.slice(0, 40)})`);
|
|
1358
1351
|
ctx.ui.notify(
|
|
1359
1352
|
[
|
|
1360
|
-
`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"}`,
|
|
1361
1354
|
`Current: ${current}`,
|
|
1362
1355
|
`Rotation (${rotation.length}): ${rotation.join(" → ") || "none — log in to an account"}`,
|
|
1363
1356
|
`Registered login slots: ${[...registeredSlots].join(", ") || "(base accounts only)"}`,
|
|
@@ -1402,7 +1395,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1402
1395
|
expectingSelfContinuation = false;
|
|
1403
1396
|
lastSentContinuationPrompt = "";
|
|
1404
1397
|
ctx.ui.notify(
|
|
1405
|
-
`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}`,
|
|
1406
1399
|
"info",
|
|
1407
1400
|
);
|
|
1408
1401
|
});
|
|
@@ -1455,14 +1448,36 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1455
1448
|
refreshDiscovery(); // cheap: only re-scans when auth.json changed (new /login)
|
|
1456
1449
|
});
|
|
1457
1450
|
|
|
1451
|
+
// Detect a MANUAL model/account selection by the user (vs our own failover setModel) and
|
|
1452
|
+
// pin it, so auto-failover won't immediately yank them off it.
|
|
1453
|
+
pi.on("model_select", (event) => {
|
|
1454
|
+
if (selfModelSwitch) return; // our own failover switch — not a manual pick
|
|
1455
|
+
const model = (event as any).model;
|
|
1456
|
+
if (model?.provider) userSelectedProvider = model.provider;
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1458
1459
|
pi.on("after_provider_response", async (event, ctx) => {
|
|
1459
1460
|
latestCtx = ctx;
|
|
1460
1461
|
if (!config.enabled) return;
|
|
1461
1462
|
if (userAbortedChain || ctx.signal?.aborted) return; // user is cancelling — don't fail over
|
|
1462
1463
|
const status = (event as any).status;
|
|
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
|
+
}
|
|
1463
1470
|
if (status === 401) {
|
|
1464
|
-
//
|
|
1465
|
-
|
|
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
|
+
}
|
|
1466
1481
|
await switchToFallback(ctx, "HTTP 401 (auth invalid)");
|
|
1467
1482
|
return;
|
|
1468
1483
|
}
|
|
@@ -1480,7 +1495,13 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1480
1495
|
lastErrorText = errorText;
|
|
1481
1496
|
if (currentPromptSwitch) return;
|
|
1482
1497
|
if (isAuthError(errorText)) {
|
|
1483
|
-
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
|
+
}
|
|
1484
1505
|
await switchToFallback(ctx, `auth invalid: ${errorText.slice(0, 100)}`);
|
|
1485
1506
|
return;
|
|
1486
1507
|
}
|
|
@@ -1510,7 +1531,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1510
1531
|
if (userAbortedChain) return;
|
|
1511
1532
|
|
|
1512
1533
|
const errorText = lastErrorText || getAssistantErrorText((event as any).messages ?? []);
|
|
1513
|
-
if (isAuthError(errorText) && ctx.model)
|
|
1534
|
+
if (isAuthError(errorText) && ctx.model) markAuthFailure(ctx.model.provider, errorText.slice(0, 60));
|
|
1514
1535
|
if (!isLimitError(errorText) && !isAuthError(errorText)) return;
|
|
1515
1536
|
|
|
1516
1537
|
// Task-level cap. Because this counter is no longer reset by our own re-prompts,
|
|
@@ -1536,9 +1557,10 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1536
1557
|
}
|
|
1537
1558
|
|
|
1538
1559
|
if (currentPromptSwitch) {
|
|
1539
|
-
autoContinuesThisPrompt++;
|
|
1540
1560
|
const prompt = continuationPrompt(currentPromptSwitch);
|
|
1541
|
-
|
|
1561
|
+
// Continue synchronously and only if it actually sent (agent idle, not aborted).
|
|
1562
|
+
// Count the attempt only when we really sent, so the cap reflects real tries.
|
|
1563
|
+
if (scheduleAutoContinue(ctx, prompt)) autoContinuesThisPrompt++;
|
|
1542
1564
|
}
|
|
1543
1565
|
});
|
|
1544
1566
|
}
|
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",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
],
|
|
43
43
|
"scripts": {
|
|
44
44
|
"check": "tsc --noEmit",
|
|
45
|
+
"test": "node --test test/*.test.ts",
|
|
45
46
|
"pack:check": "npm pack --dry-run",
|
|
46
47
|
"prepublishOnly": "npm run check"
|
|
47
48
|
},
|