pi-multi-account 1.0.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 (5) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/index.ts +1184 -0
  5. package/package.json +69 -0
package/index.ts ADDED
@@ -0,0 +1,1184 @@
1
+ /**
2
+ * pi-multi-account — automatic multi-account failover & rotation for Pi.
3
+ *
4
+ * What it does
5
+ * ------------
6
+ * - Auto-discovers every authenticated account from ~/.pi/agent/auth.json
7
+ * (Anthropic Claude Pro/Max, OpenAI/ChatGPT Codex, and Qwen/Alibaba) and
8
+ * builds the failover rotation dynamically — no manual config editing.
9
+ * - Pre-registers a pool of login slots so you can simply run
10
+ * `/login anthropic-account-3` (or `openai-codex-account-5`) to add an
11
+ * account; the next discovery sweep adds it to the rotation automatically.
12
+ * - Drops an account from the rotation the moment its token is expired or its
13
+ * authorization is revoked, and restores it automatically once you re-login.
14
+ * - On a quota/rate-limit (429/402/403) it marks the account on cooldown and
15
+ * transparently switches to the next available account/model, optionally
16
+ * queuing a safe continuation prompt.
17
+ *
18
+ * Anthropic OAuth aliases require `@gotgenes/pi-anthropic-auth` for request
19
+ * shaping (its before_provider_request hook is provider-agnostic and covers
20
+ * every Anthropic OAuth alias this package registers).
21
+ *
22
+ * Config: ~/.pi/agent/provider-failover.json
23
+ * State: ~/.pi/agent/provider-failover-state.json
24
+ */
25
+
26
+ import { createHash } from "node:crypto";
27
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
28
+ import { homedir } from "node:os";
29
+ import { dirname, join } from "node:path";
30
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
31
+ import { getModel } from "@earendil-works/pi-ai";
32
+ import { loginAnthropic, openaiCodexOAuthProvider, refreshAnthropicToken } from "@earendil-works/pi-ai/oauth";
33
+
34
+ type ModelRef = `${string}/${string}`;
35
+ type ProviderFamily = "anthropic" | "openai-codex" | "qwen";
36
+
37
+ type OpenAICodexAliasConfig = { id: string; displayName?: string; models?: string[] };
38
+ type AnthropicOAuthAliasConfig = { id: string; displayName?: string; models?: string[] };
39
+
40
+ type ProviderFailoverConfig = {
41
+ enabled?: boolean;
42
+ autoContinue?: boolean;
43
+ autoDiscover?: boolean;
44
+ maxAccountsPerProvider?: number;
45
+ includeQwen?: boolean;
46
+ qwenProvider?: string;
47
+ providerOrder?: ProviderFamily[];
48
+ cooldownMs?: number;
49
+ probeCooldownMs?: number;
50
+ invalidCooldownMs?: number;
51
+ maxAutoContinuesPerPrompt?: number;
52
+ fallbacks?: string[];
53
+ openaiCodexAliases?: OpenAICodexAliasConfig[];
54
+ anthropicOAuthAliases?: AnthropicOAuthAliasConfig[];
55
+ limitErrorPatterns?: string[];
56
+ authErrorPatterns?: string[];
57
+ ignoreErrorPatterns?: string[];
58
+ continuationPrompt?: string;
59
+ };
60
+
61
+ type RuntimeConfig = Required<
62
+ Pick<
63
+ ProviderFailoverConfig,
64
+ | "enabled"
65
+ | "autoContinue"
66
+ | "autoDiscover"
67
+ | "maxAccountsPerProvider"
68
+ | "includeQwen"
69
+ | "qwenProvider"
70
+ | "providerOrder"
71
+ | "cooldownMs"
72
+ | "probeCooldownMs"
73
+ | "invalidCooldownMs"
74
+ | "maxAutoContinuesPerPrompt"
75
+ | "fallbacks"
76
+ | "limitErrorPatterns"
77
+ | "authErrorPatterns"
78
+ | "ignoreErrorPatterns"
79
+ | "continuationPrompt"
80
+ >
81
+ > & {
82
+ openaiCodexAliases: OpenAICodexAliasConfig[];
83
+ anthropicOAuthAliases: AnthropicOAuthAliasConfig[];
84
+ };
85
+
86
+ type SwitchRecord = { from: ModelRef; to: ModelRef; reason: string; at: number };
87
+
88
+ type InvalidationRecord = { tokenHash: string; at: number; reason: string };
89
+
90
+ type ProviderFailoverState = {
91
+ stateVersion?: number;
92
+ exhaustedUntilByProvider?: Record<string, number>;
93
+ lastProbeAtByProvider?: Record<string, number>;
94
+ invalidatedByProvider?: Record<string, InvalidationRecord>;
95
+ pendingContinuationPrompt?: string;
96
+ pendingSince?: number;
97
+ pendingReason?: string;
98
+ lastSwitches?: SwitchRecord[];
99
+ };
100
+
101
+ const AGENT_DIR = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
102
+ const CONFIG_PATH = join(AGENT_DIR, "provider-failover.json");
103
+ const STATE_PATH = join(AGENT_DIR, "provider-failover-state.json");
104
+ const AUTH_PATH = join(AGENT_DIR, "auth.json");
105
+ const STATE_VERSION = 3;
106
+ const DEFAULT_COOLDOWN_MS = 6 * 60 * 60 * 1000;
107
+ const DEFAULT_PROBE_COOLDOWN_MS = 5 * 60 * 1000;
108
+ const DEFAULT_INVALID_COOLDOWN_MS = 365 * 24 * 60 * 60 * 1000; // effectively "until re-login"
109
+
110
+ const ANTHROPIC_BASE = "anthropic";
111
+ const CODEX_BASE = "openai-codex";
112
+ const DEFAULT_QWEN_PROVIDER = "alibaba";
113
+
114
+ const DEFAULT_LIMIT_PATTERNS = [
115
+ "429",
116
+ "rate limit",
117
+ "rate_limit",
118
+ "too many requests",
119
+ "usage limit",
120
+ "usage_limit_reached",
121
+ "usage_not_included",
122
+ "quota",
123
+ "insufficient_quota",
124
+ "out of budget",
125
+ "available balance",
126
+ "billing hard limit",
127
+ "monthly usage limit",
128
+ "freeusagelimiterror",
129
+ "gousagelimiterror",
130
+ ];
131
+
132
+ // Errors that mean "this account's authorization is dead" → drop from rotation
133
+ // until the user re-logs in (not a temporary cooldown).
134
+ const DEFAULT_AUTH_ERROR_PATTERNS = [
135
+ "401",
136
+ "unauthorized",
137
+ "authentication_error",
138
+ "invalid authentication",
139
+ "invalid_token",
140
+ "invalid token",
141
+ "token has expired",
142
+ "token expired",
143
+ "expired token",
144
+ "invalid_grant",
145
+ "invalid api key",
146
+ "incorrect api key",
147
+ "no api key",
148
+ "missing api key",
149
+ "revoked",
150
+ "oauth token",
151
+ ];
152
+
153
+ const DEFAULT_IGNORE_PATTERNS = [
154
+ "context overflow",
155
+ "context window",
156
+ "context length",
157
+ "maximum context",
158
+ "too many tokens",
159
+ "token limit exceeded",
160
+ "input is too long",
161
+ ];
162
+
163
+ const DEFAULT_CONTINUATION_PROMPT = [
164
+ "Provider failover activated: the previous provider/account hit a quota or rate limit, and Pi switched to {to}.",
165
+ "Continue the interrupted user task from the last safe point.",
166
+ "Do not repeat destructive actions or duplicate completed work. If state is uncertain, inspect current files/session state first, then continue.",
167
+ ].join(" ");
168
+
169
+ const DEFAULT_PROVIDER_ORDER: ProviderFamily[] = ["anthropic", "openai-codex", "qwen"];
170
+
171
+ const CODEX_MODEL_DEFS: Record<string, Record<string, unknown>> = {
172
+ "gpt-5.3-codex-spark": {
173
+ id: "gpt-5.3-codex-spark",
174
+ name: "GPT-5.3 Codex Spark",
175
+ reasoning: true,
176
+ thinkingLevelMap: { xhigh: "xhigh", minimal: "low" },
177
+ input: ["text"],
178
+ cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
179
+ contextWindow: 128000,
180
+ maxTokens: 128000,
181
+ },
182
+ "gpt-5.4": {
183
+ id: "gpt-5.4",
184
+ name: "GPT-5.4",
185
+ reasoning: true,
186
+ thinkingLevelMap: { xhigh: "xhigh", minimal: "low" },
187
+ input: ["text", "image"],
188
+ cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
189
+ contextWindow: 272000,
190
+ maxTokens: 128000,
191
+ },
192
+ "gpt-5.4-mini": {
193
+ id: "gpt-5.4-mini",
194
+ name: "GPT-5.4 mini",
195
+ reasoning: true,
196
+ thinkingLevelMap: { xhigh: "xhigh", minimal: "low" },
197
+ input: ["text", "image"],
198
+ cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
199
+ contextWindow: 272000,
200
+ maxTokens: 128000,
201
+ },
202
+ "gpt-5.5": {
203
+ id: "gpt-5.5",
204
+ name: "GPT-5.5",
205
+ reasoning: true,
206
+ thinkingLevelMap: { xhigh: "xhigh", minimal: "low" },
207
+ input: ["text", "image"],
208
+ cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
209
+ contextWindow: 272000,
210
+ maxTokens: 128000,
211
+ },
212
+ };
213
+
214
+ const DEFAULT_CODEX_MODELS = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"];
215
+ const DEFAULT_ANTHROPIC_MODELS = ["claude-opus-4-8", "claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"];
216
+
217
+ const DEFAULT_CONFIG: ProviderFailoverConfig = {
218
+ enabled: true,
219
+ autoContinue: true,
220
+ autoDiscover: true,
221
+ maxAccountsPerProvider: 10,
222
+ includeQwen: true,
223
+ qwenProvider: DEFAULT_QWEN_PROVIDER,
224
+ providerOrder: DEFAULT_PROVIDER_ORDER,
225
+ cooldownMs: DEFAULT_COOLDOWN_MS,
226
+ probeCooldownMs: DEFAULT_PROBE_COOLDOWN_MS,
227
+ invalidCooldownMs: DEFAULT_INVALID_COOLDOWN_MS,
228
+ maxAutoContinuesPerPrompt: 8,
229
+ fallbacks: [],
230
+ openaiCodexAliases: [],
231
+ anthropicOAuthAliases: [],
232
+ limitErrorPatterns: DEFAULT_LIMIT_PATTERNS,
233
+ authErrorPatterns: DEFAULT_AUTH_ERROR_PATTERNS,
234
+ ignoreErrorPatterns: DEFAULT_IGNORE_PATTERNS,
235
+ continuationPrompt: DEFAULT_CONTINUATION_PROMPT,
236
+ };
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Config + state persistence
240
+ // ---------------------------------------------------------------------------
241
+
242
+ function ensureDefaultConfig() {
243
+ if (existsSync(CONFIG_PATH)) return;
244
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true, mode: 0o700 });
245
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(DEFAULT_CONFIG, null, "\t")}\n`, { encoding: "utf8", mode: 0o600 });
246
+ }
247
+
248
+ function positiveOr(value: unknown, fallback: number) {
249
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
250
+ }
251
+
252
+ function normalizeConfig(raw: ProviderFailoverConfig): RuntimeConfig {
253
+ const order = Array.isArray(raw.providerOrder) && raw.providerOrder.length > 0 ? raw.providerOrder : DEFAULT_PROVIDER_ORDER;
254
+ return {
255
+ enabled: raw.enabled ?? true,
256
+ autoContinue: raw.autoContinue ?? true,
257
+ autoDiscover: raw.autoDiscover ?? true,
258
+ maxAccountsPerProvider: Math.max(1, Math.floor(positiveOr(raw.maxAccountsPerProvider, 10))),
259
+ includeQwen: raw.includeQwen ?? true,
260
+ qwenProvider: raw.qwenProvider?.trim() || DEFAULT_QWEN_PROVIDER,
261
+ providerOrder: order.filter((f): f is ProviderFamily => f === "anthropic" || f === "openai-codex" || f === "qwen"),
262
+ cooldownMs: positiveOr(raw.cooldownMs, DEFAULT_COOLDOWN_MS),
263
+ probeCooldownMs: positiveOr(raw.probeCooldownMs, DEFAULT_PROBE_COOLDOWN_MS),
264
+ invalidCooldownMs: positiveOr(raw.invalidCooldownMs, DEFAULT_INVALID_COOLDOWN_MS),
265
+ maxAutoContinuesPerPrompt: Math.floor(positiveOr(raw.maxAutoContinuesPerPrompt, 8)),
266
+ fallbacks: Array.isArray(raw.fallbacks) ? raw.fallbacks : [],
267
+ openaiCodexAliases: Array.isArray(raw.openaiCodexAliases) ? raw.openaiCodexAliases : [],
268
+ anthropicOAuthAliases: Array.isArray(raw.anthropicOAuthAliases) ? raw.anthropicOAuthAliases : [],
269
+ limitErrorPatterns: Array.isArray(raw.limitErrorPatterns) && raw.limitErrorPatterns.length > 0 ? raw.limitErrorPatterns : DEFAULT_LIMIT_PATTERNS,
270
+ authErrorPatterns: Array.isArray(raw.authErrorPatterns) && raw.authErrorPatterns.length > 0 ? raw.authErrorPatterns : DEFAULT_AUTH_ERROR_PATTERNS,
271
+ ignoreErrorPatterns: Array.isArray(raw.ignoreErrorPatterns) && raw.ignoreErrorPatterns.length > 0 ? raw.ignoreErrorPatterns : DEFAULT_IGNORE_PATTERNS,
272
+ continuationPrompt: raw.continuationPrompt?.trim() || DEFAULT_CONTINUATION_PROMPT,
273
+ };
274
+ }
275
+
276
+ function loadConfig(): RuntimeConfig {
277
+ ensureDefaultConfig();
278
+ try {
279
+ return normalizeConfig(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as ProviderFailoverConfig);
280
+ } catch {
281
+ return normalizeConfig(DEFAULT_CONFIG);
282
+ }
283
+ }
284
+
285
+ function loadState(): ProviderFailoverState {
286
+ try {
287
+ if (!existsSync(STATE_PATH)) return { stateVersion: STATE_VERSION };
288
+ const state = JSON.parse(readFileSync(STATE_PATH, "utf8")) as ProviderFailoverState;
289
+ if (state.stateVersion !== STATE_VERSION) {
290
+ // Preserve invalidations across upgrades; drop stale cooldowns.
291
+ return { stateVersion: STATE_VERSION, invalidatedByProvider: state.invalidatedByProvider ?? {} };
292
+ }
293
+ return state;
294
+ } catch {
295
+ return { stateVersion: STATE_VERSION };
296
+ }
297
+ }
298
+
299
+ function saveState(state: ProviderFailoverState) {
300
+ mkdirSync(dirname(STATE_PATH), { recursive: true, mode: 0o700 });
301
+ writeFileSync(STATE_PATH, `${JSON.stringify({ stateVersion: STATE_VERSION, ...state }, null, "\t")}\n`, { encoding: "utf8", mode: 0o600 });
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // auth.json reading, account identity & token validity
306
+ // ---------------------------------------------------------------------------
307
+
308
+ type AuthEntry = { type?: string; access?: string; refresh?: string; key?: string };
309
+
310
+ function readAuthFile(): Record<string, AuthEntry> {
311
+ try {
312
+ return JSON.parse(readFileSync(AUTH_PATH, "utf8")) as Record<string, AuthEntry>;
313
+ } catch {
314
+ return {};
315
+ }
316
+ }
317
+
318
+ function authMtimeMs(): number {
319
+ try {
320
+ return statSync(AUTH_PATH).mtimeMs;
321
+ } catch {
322
+ return 0;
323
+ }
324
+ }
325
+
326
+ function decodeJwtPayload(token: string): any | undefined {
327
+ const parts = token.split(".");
328
+ if (parts.length !== 3) return undefined;
329
+ try {
330
+ let payload = parts[1].replaceAll("-", "+").replaceAll("_", "/");
331
+ payload += "=".repeat((4 - (payload.length % 4)) % 4);
332
+ return JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
333
+ } catch {
334
+ return undefined;
335
+ }
336
+ }
337
+
338
+ function jwtExpMs(token: string): number | undefined {
339
+ const exp = decodeJwtPayload(token)?.exp;
340
+ return typeof exp === "number" && Number.isFinite(exp) ? exp * 1000 : undefined;
341
+ }
342
+
343
+ function getCodexAccountIdFromAccessToken(token: string): string | undefined {
344
+ return decodeJwtPayload(token)?.["https://api.openai.com/auth"]?.chatgpt_account_id as string | undefined;
345
+ }
346
+
347
+ function hash12(input: string) {
348
+ return createHash("sha256").update(input).digest("hex").slice(0, 12);
349
+ }
350
+
351
+ /** A stable fingerprint of the current credential, used to detect re-login. */
352
+ function credentialHash(entry: AuthEntry): string | undefined {
353
+ const secret = entry.access ?? entry.key;
354
+ return secret ? hash12(secret) : undefined;
355
+ }
356
+
357
+ /** Identity used to dedupe the same real account logged into multiple slots. */
358
+ function accountIdentity(entry: AuthEntry): string | undefined {
359
+ if (entry.access) {
360
+ const codexId = getCodexAccountIdFromAccessToken(entry.access);
361
+ if (codexId) return `codex:${hash12(codexId)}`;
362
+ return `tok:${hash12(entry.access)}`;
363
+ }
364
+ if (entry.key) return `key:${hash12(entry.key)}`;
365
+ return undefined;
366
+ }
367
+
368
+ /** True when the credential is present and not provably dead. */
369
+ function isEntryUsable(entry: AuthEntry | undefined): boolean {
370
+ if (!entry) return false;
371
+ if (entry.type === "api_key" || entry.key) return typeof entry.key === "string" && entry.key.length > 0;
372
+ if (typeof entry.access !== "string" || entry.access.length === 0) return false;
373
+ // Expired access token with no refresh token → unrecoverable.
374
+ const expMs = jwtExpMs(entry.access);
375
+ if (expMs !== undefined && expMs <= Date.now() && !(typeof entry.refresh === "string" && entry.refresh.length > 0)) {
376
+ return false;
377
+ }
378
+ return true;
379
+ }
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // Provider id helpers
383
+ // ---------------------------------------------------------------------------
384
+
385
+ function classifyProvider(id: string, qwenProvider: string): ProviderFamily | undefined {
386
+ if (id === ANTHROPIC_BASE || /^anthropic-account-\d+$/.test(id)) return "anthropic";
387
+ if (id === CODEX_BASE || /^openai-codex-account-\d+$/.test(id)) return "openai-codex";
388
+ if (id === qwenProvider || /^qwen/i.test(id)) return "qwen";
389
+ return undefined;
390
+ }
391
+
392
+ function slotIndex(id: string): number {
393
+ const m = id.match(/-account-(\d+)$/);
394
+ return m ? Number(m[1]) : 1; // base provider counts as slot 1
395
+ }
396
+
397
+ function slotId(family: "anthropic" | "openai-codex", index: number): string {
398
+ const base = family === "anthropic" ? ANTHROPIC_BASE : CODEX_BASE;
399
+ return index <= 1 ? base : `${base}-account-${index}`;
400
+ }
401
+
402
+ function ref(provider: string, modelId: string): ModelRef {
403
+ return `${provider}/${modelId}` as ModelRef;
404
+ }
405
+
406
+ function parseTarget(target: string): { provider: string; modelId?: string } | undefined {
407
+ const trimmed = target.trim();
408
+ if (!trimmed) return undefined;
409
+ const slash = trimmed.indexOf("/");
410
+ if (slash === -1) return { provider: trimmed };
411
+ const provider = trimmed.slice(0, slash).trim();
412
+ const modelId = trimmed.slice(slash + 1).trim();
413
+ if (!provider || !modelId) return undefined;
414
+ return { provider, modelId };
415
+ }
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // Model definitions for registered alias providers
419
+ // ---------------------------------------------------------------------------
420
+
421
+ function anthropicModelDef(id: string, providerId: string) {
422
+ const canonical = getModel("anthropic", id as any) as any;
423
+ if (canonical) return { ...canonical, provider: providerId };
424
+ return {
425
+ id,
426
+ name: id,
427
+ api: "anthropic-messages",
428
+ provider: providerId,
429
+ baseUrl: "https://api.anthropic.com",
430
+ reasoning: true,
431
+ input: ["text", "image"],
432
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
433
+ contextWindow: 200000,
434
+ maxTokens: 32000,
435
+ };
436
+ }
437
+
438
+ function codexModelDef(id: string) {
439
+ return (
440
+ CODEX_MODEL_DEFS[id] ?? {
441
+ id,
442
+ name: id,
443
+ reasoning: true,
444
+ thinkingLevelMap: { xhigh: "xhigh", minimal: "low" },
445
+ input: ["text", "image"],
446
+ contextWindow: 272000,
447
+ maxTokens: 128000,
448
+ }
449
+ );
450
+ }
451
+
452
+ function registerAnthropicSlot(pi: ExtensionAPI, id: string) {
453
+ if (id === ANTHROPIC_BASE) return; // base provider is native / shaped by pi-anthropic-auth
454
+ const models = DEFAULT_ANTHROPIC_MODELS.map((m) => anthropicModelDef(m, id));
455
+ pi.registerProvider(id, {
456
+ name: `Claude Pro/Max (${id})`,
457
+ baseUrl: "https://api.anthropic.com",
458
+ api: "anthropic-messages" as any,
459
+ oauth: {
460
+ name: `Claude Pro/Max (${id})`,
461
+ login: (callbacks: any) => loginAnthropic(callbacks),
462
+ async refreshToken(credentials: any) {
463
+ const refreshed = await refreshAnthropicToken(credentials.refresh);
464
+ return {
465
+ ...credentials,
466
+ refresh:
467
+ typeof refreshed.refresh === "string" && refreshed.refresh.trim().length > 0 ? refreshed.refresh : credentials.refresh,
468
+ };
469
+ },
470
+ getApiKey: (credentials: any) => credentials.access,
471
+ },
472
+ models: models as any,
473
+ });
474
+ }
475
+
476
+ function registerCodexSlot(pi: ExtensionAPI, id: string) {
477
+ if (id === CODEX_BASE) return; // base provider is native
478
+ const models = DEFAULT_CODEX_MODELS.map(codexModelDef);
479
+ pi.registerProvider(id, {
480
+ name: `ChatGPT Plus/Pro (Codex ${id})`,
481
+ baseUrl: "https://chatgpt.com/backend-api",
482
+ api: "openai-codex-responses" as any,
483
+ oauth: {
484
+ name: `ChatGPT Plus/Pro (Codex ${id})`,
485
+ login: openaiCodexOAuthProvider.login,
486
+ refreshToken: openaiCodexOAuthProvider.refreshToken,
487
+ getApiKey: openaiCodexOAuthProvider.getApiKey,
488
+ },
489
+ models: models as any,
490
+ });
491
+ }
492
+
493
+ // ---------------------------------------------------------------------------
494
+ // Misc helpers (cooldown parsing from headers / error bodies)
495
+ // ---------------------------------------------------------------------------
496
+
497
+ function patternMatch(text: string, patterns: string[]) {
498
+ const lower = text.toLowerCase();
499
+ return patterns.some((p) => p && lower.includes(p.toLowerCase()));
500
+ }
501
+
502
+ function retryAfterToMs(value: string | undefined) {
503
+ if (!value) return undefined;
504
+ const seconds = Number(value);
505
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds * 1000);
506
+ const dateMs = Date.parse(value);
507
+ if (Number.isFinite(dateMs)) return Math.max(0, dateMs - Date.now());
508
+ return undefined;
509
+ }
510
+
511
+ function secondsToMs(value: string | undefined) {
512
+ if (!value) return undefined;
513
+ const seconds = Number(value);
514
+ return Number.isFinite(seconds) && seconds >= 0 ? Math.ceil(seconds * 1000) : undefined;
515
+ }
516
+
517
+ function unixSecondsToCooldownMs(value: string | undefined) {
518
+ if (!value) return undefined;
519
+ const seconds = Number(value);
520
+ return Number.isFinite(seconds) && seconds > 0 ? Math.max(0, seconds * 1000 - Date.now()) : undefined;
521
+ }
522
+
523
+ function firstDefinedMs(values: Array<number | undefined>) {
524
+ return values.find((value): value is number => value !== undefined && Number.isFinite(value) && value >= 0);
525
+ }
526
+
527
+ function percentValue(value: string | undefined) {
528
+ const numeric = Number(value);
529
+ return Number.isFinite(numeric) ? numeric : undefined;
530
+ }
531
+
532
+ function cooldownFromHeaders(headers: Record<string, string>) {
533
+ const normalized = new Map(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
534
+ const get = (name: string) => normalized.get(name.toLowerCase());
535
+ const primaryUsed = percentValue(get("x-codex-primary-used-percent"));
536
+ const secondaryUsed = percentValue(get("x-codex-secondary-used-percent"));
537
+ const retryAfter = retryAfterToMs(get("retry-after"));
538
+ const primaryReset = firstDefinedMs([secondsToMs(get("x-codex-primary-reset-after-seconds")), unixSecondsToCooldownMs(get("x-codex-primary-reset-at"))]);
539
+ const secondaryReset = firstDefinedMs([secondsToMs(get("x-codex-secondary-reset-after-seconds")), unixSecondsToCooldownMs(get("x-codex-secondary-reset-at"))]);
540
+ if ((secondaryUsed ?? 0) >= 100) return secondaryReset ?? retryAfter ?? primaryReset;
541
+ if ((primaryUsed ?? 0) >= 100) return primaryReset ?? retryAfter ?? secondaryReset;
542
+ return retryAfter ?? primaryReset ?? secondaryReset;
543
+ }
544
+
545
+ function cooldownFromErrorText(errorText: string) {
546
+ const bodyReset = firstDefinedMs([
547
+ secondsToMs(errorText.match(/"resets_in_seconds"\s*:\s*(\d+)/i)?.[1]),
548
+ unixSecondsToCooldownMs(errorText.match(/"resets_at"\s*:\s*(\d+)/i)?.[1]),
549
+ ]);
550
+ if (bodyReset !== undefined) return bodyReset;
551
+ const primaryUsed = percentValue(errorText.match(/"X-Codex-Primary-Used-Percent"\s*:\s*"?(\d+)/i)?.[1]);
552
+ const secondaryUsed = percentValue(errorText.match(/"X-Codex-Secondary-Used-Percent"\s*:\s*"?(\d+)/i)?.[1]);
553
+ const primaryReset = firstDefinedMs([
554
+ secondsToMs(errorText.match(/"X-Codex-Primary-Reset-After-Seconds"\s*:\s*"?(\d+)/i)?.[1]),
555
+ unixSecondsToCooldownMs(errorText.match(/"X-Codex-Primary-Reset-At"\s*:\s*"?(\d+)/i)?.[1]),
556
+ ]);
557
+ const secondaryReset = firstDefinedMs([
558
+ secondsToMs(errorText.match(/"X-Codex-Secondary-Reset-After-Seconds"\s*:\s*"?(\d+)/i)?.[1]),
559
+ unixSecondsToCooldownMs(errorText.match(/"X-Codex-Secondary-Reset-At"\s*:\s*"?(\d+)/i)?.[1]),
560
+ ]);
561
+ if ((secondaryUsed ?? 0) >= 100) return secondaryReset ?? primaryReset;
562
+ if ((primaryUsed ?? 0) >= 100) return primaryReset ?? secondaryReset;
563
+ return primaryReset ?? secondaryReset;
564
+ }
565
+
566
+ function formatUntil(timestamp: number) {
567
+ const ms = timestamp - Date.now();
568
+ if (ms <= 0) return "expired";
569
+ const minutes = Math.ceil(ms / 60000);
570
+ if (minutes < 60) return `${minutes}m`;
571
+ const hours = Math.floor(minutes / 60);
572
+ const rest = minutes % 60;
573
+ return rest ? `${hours}h ${rest}m` : `${hours}h`;
574
+ }
575
+
576
+ function getAssistantErrorText(messages: any[]) {
577
+ for (let i = messages.length - 1; i >= 0; i--) {
578
+ const message = messages[i];
579
+ if (message?.role !== "assistant") continue;
580
+ const error = typeof message.errorMessage === "string" ? message.errorMessage : "";
581
+ if (error) return error;
582
+ if (message.stopReason === "error") {
583
+ return Array.isArray(message.content)
584
+ ? message.content.filter((b: any) => b?.type === "text" && typeof b.text === "string").map((b: any) => b.text).join("\n")
585
+ : "";
586
+ }
587
+ }
588
+ return "";
589
+ }
590
+
591
+ // ===========================================================================
592
+ // Extension entry point
593
+ // ===========================================================================
594
+
595
+ export default function piMultiAccount(pi: ExtensionAPI) {
596
+ let config = loadConfig();
597
+ let persistedState = loadState();
598
+
599
+ const exhaustedUntilByProvider = new Map<string, number>(Object.entries(persistedState.exhaustedUntilByProvider ?? {}));
600
+ const invalidatedByProvider = new Map<string, InvalidationRecord>(Object.entries(persistedState.invalidatedByProvider ?? {}));
601
+
602
+ // Discovered, authed, deduped provider ids in rotation order.
603
+ let rotation: string[] = [];
604
+ // Slot ids registered as login targets (so /login <id> works).
605
+ const registeredSlots = new Set<string>();
606
+ let lastAuthMtime = -1;
607
+
608
+ let currentPromptSwitch: SwitchRecord | undefined;
609
+ let autoContinuesThisPrompt = 0;
610
+ let lastErrorText = "";
611
+ let latestCtx: any | undefined;
612
+ let pendingWakeTimer: ReturnType<typeof setTimeout> | undefined;
613
+ // The thinking level the user intended for this turn. pi.setModel() re-clamps and
614
+ // persists the thinking level on every model switch, so without this it drifts
615
+ // downward across failovers ("thinking level keeps dropping"). We capture it before
616
+ // any switch and re-assert it after each successful switch.
617
+ let desiredThinkingLevel: any;
618
+
619
+ function captureDesiredThinking() {
620
+ try {
621
+ const level = (pi as any).getThinkingLevel?.();
622
+ if (level) desiredThinkingLevel = level;
623
+ } catch {
624
+ /* getThinkingLevel may be unavailable on older Pi — degrade gracefully */
625
+ }
626
+ }
627
+
628
+ function restoreDesiredThinking() {
629
+ if (!desiredThinkingLevel) return;
630
+ try {
631
+ (pi as any).setThinkingLevel?.(desiredThinkingLevel);
632
+ } catch {
633
+ /* setThinkingLevel clamps to model caps; ignore if unsupported */
634
+ }
635
+ }
636
+
637
+ function persist(extra?: Partial<ProviderFailoverState>) {
638
+ persistedState = {
639
+ ...persistedState,
640
+ ...extra,
641
+ stateVersion: STATE_VERSION,
642
+ exhaustedUntilByProvider: Object.fromEntries(exhaustedUntilByProvider.entries()),
643
+ invalidatedByProvider: Object.fromEntries(invalidatedByProvider.entries()),
644
+ };
645
+ saveState(persistedState);
646
+ }
647
+
648
+ function lastProbeMap() {
649
+ return persistedState.lastProbeAtByProvider ?? {};
650
+ }
651
+
652
+ function setLastProbe(provider: string) {
653
+ persistedState = { ...persistedState, lastProbeAtByProvider: { ...lastProbeMap(), [provider]: Date.now() } };
654
+ persist();
655
+ }
656
+
657
+ // ----- invalidation (dead authorization) --------------------------------
658
+
659
+ function clearReauthedInvalidations(auth: Record<string, AuthEntry>) {
660
+ let changed = false;
661
+ for (const [provider, record] of [...invalidatedByProvider.entries()]) {
662
+ const entry = auth[provider];
663
+ const currentHash = entry ? credentialHash(entry) : undefined;
664
+ // Re-login (credential changed) or entry removed → clear invalidation.
665
+ if (!entry || (currentHash && currentHash !== record.tokenHash)) {
666
+ invalidatedByProvider.delete(provider);
667
+ changed = true;
668
+ }
669
+ }
670
+ if (changed) persist();
671
+ }
672
+
673
+ function markInvalid(provider: string, reason: string) {
674
+ const entry = readAuthFile()[provider];
675
+ const tokenHash = entry ? credentialHash(entry) ?? "" : "";
676
+ invalidatedByProvider.set(provider, { tokenHash, at: Date.now(), reason });
677
+ // Also keep a long cooldown so in-flight selection logic skips it immediately.
678
+ exhaustedUntilByProvider.set(provider, Date.now() + config.invalidCooldownMs);
679
+ persist();
680
+ }
681
+
682
+ function isInvalidated(provider: string) {
683
+ return invalidatedByProvider.has(provider);
684
+ }
685
+
686
+ // ----- cooldowns --------------------------------------------------------
687
+
688
+ function pruneCooldowns() {
689
+ const now = Date.now();
690
+ let changed = false;
691
+ for (const [provider, until] of exhaustedUntilByProvider) {
692
+ if (until <= now && !isInvalidated(provider)) {
693
+ exhaustedUntilByProvider.delete(provider);
694
+ changed = true;
695
+ }
696
+ }
697
+ if (changed) persist();
698
+ }
699
+
700
+ function providersSharingAccount(provider: string): string[] {
701
+ const auth = readAuthFile();
702
+ const identity = auth[provider] ? accountIdentity(auth[provider]) : undefined;
703
+ if (!identity) return [provider];
704
+ const shared = rotation.filter((p) => {
705
+ const e = auth[p];
706
+ return e && accountIdentity(e) === identity;
707
+ });
708
+ return shared.length > 0 ? shared : [provider];
709
+ }
710
+
711
+ function markExhausted(provider: string, cooldownMs: number) {
712
+ const until = Date.now() + Math.max(cooldownMs, 1000);
713
+ for (const candidate of providersSharingAccount(provider)) {
714
+ exhaustedUntilByProvider.set(candidate, Math.max(exhaustedUntilByProvider.get(candidate) ?? 0, until));
715
+ }
716
+ persist();
717
+ }
718
+
719
+ // ----- discovery & dynamic rotation -------------------------------------
720
+
721
+ function discoverRotation(auth: Record<string, AuthEntry>): string[] {
722
+ const byFamily: Record<ProviderFamily, string[]> = { anthropic: [], "openai-codex": [], qwen: [] };
723
+ for (const [id, entry] of Object.entries(auth)) {
724
+ const family = classifyProvider(id, config.qwenProvider);
725
+ if (!family) continue;
726
+ if (family === "qwen" && !config.includeQwen) continue;
727
+ if (!isEntryUsable(entry)) continue;
728
+ if (isInvalidated(id)) continue;
729
+ byFamily[family].push(id);
730
+ }
731
+ // Sort each family base-first, then account-2,3,... and dedupe real accounts.
732
+ const order = config.providerOrder.length ? config.providerOrder : DEFAULT_PROVIDER_ORDER;
733
+ const seenIdentity = new Set<string>();
734
+ const result: string[] = [];
735
+ for (const family of order) {
736
+ const ids = byFamily[family].sort((a, b) => slotIndex(a) - slotIndex(b));
737
+ for (const id of ids) {
738
+ const identity = accountIdentity(auth[id]);
739
+ if (identity && seenIdentity.has(identity)) continue; // same account in two slots
740
+ if (identity) seenIdentity.add(identity);
741
+ result.push(id);
742
+ }
743
+ }
744
+ return result;
745
+ }
746
+
747
+ /** Register authed alias slots plus one spare per family for the next /login. */
748
+ function syncRegisteredSlots(auth: Record<string, AuthEntry>) {
749
+ for (const family of ["anthropic", "openai-codex"] as const) {
750
+ const authedIndexes = Object.keys(auth)
751
+ .filter((id) => classifyProvider(id, config.qwenProvider) === family && isEntryUsable(auth[id]))
752
+ .map(slotIndex);
753
+ const wanted = new Set<number>(authedIndexes);
754
+ // add the next free slot (>=2) so the user can /login a fresh account
755
+ let spare = 2;
756
+ while (wanted.has(spare) && spare <= config.maxAccountsPerProvider) spare++;
757
+ if (spare <= config.maxAccountsPerProvider) wanted.add(spare);
758
+ for (const index of wanted) {
759
+ if (index <= 1) continue; // base provider is native
760
+ const id = slotId(family, index);
761
+ if (registeredSlots.has(id)) continue;
762
+ if (family === "anthropic") registerAnthropicSlot(pi, id);
763
+ else registerCodexSlot(pi, id);
764
+ registeredSlots.add(id);
765
+ }
766
+ }
767
+ }
768
+
769
+ function buildFallbacks(): string[] {
770
+ if (!config.autoDiscover) {
771
+ return config.fallbacks.length > 0 ? config.fallbacks : rotation.slice();
772
+ }
773
+ // Manual fallbacks (if any) take priority, then discovered rotation.
774
+ const merged = [...config.fallbacks, ...rotation];
775
+ return [...new Set(merged)];
776
+ }
777
+
778
+ function refreshDiscovery(force = false): boolean {
779
+ const mtime = authMtimeMs();
780
+ if (!force && mtime === lastAuthMtime) return false;
781
+ lastAuthMtime = mtime;
782
+ const auth = readAuthFile();
783
+ clearReauthedInvalidations(auth);
784
+ syncRegisteredSlots(auth);
785
+ rotation = discoverRotation(auth);
786
+ config = { ...config, fallbacks: buildFallbacks() };
787
+ return true;
788
+ }
789
+
790
+ function activeFallbacks(): string[] {
791
+ return config.fallbacks.filter((t) => parseTarget(t));
792
+ }
793
+
794
+ function configuredProviders(): string[] {
795
+ return [...new Set(activeFallbacks().map((t) => parseTarget(t)?.provider).filter((p): p is string => !!p))];
796
+ }
797
+
798
+ function isExhausted(provider: string) {
799
+ pruneCooldowns();
800
+ return (exhaustedUntilByProvider.get(provider) ?? 0) > Date.now();
801
+ }
802
+
803
+ // ----- fallback selection ----------------------------------------------
804
+
805
+ function resolveTarget(ctx: any, target: string, currentModel: any) {
806
+ const parsed = parseTarget(target);
807
+ if (!parsed) return undefined;
808
+ if (parsed.modelId) return ctx.modelRegistry.find(parsed.provider, parsed.modelId);
809
+
810
+ const sameModel = currentModel?.id ? ctx.modelRegistry.find(parsed.provider, currentModel.id) : undefined;
811
+ if (sameModel) return sameModel;
812
+
813
+ const family = classifyProvider(parsed.provider, config.qwenProvider);
814
+ const preferred = family === "anthropic" ? DEFAULT_ANTHROPIC_MODELS : family === "openai-codex" ? DEFAULT_CODEX_MODELS : [];
815
+ for (const modelId of preferred) {
816
+ const model = ctx.modelRegistry.find(parsed.provider, modelId);
817
+ if (model) return model;
818
+ }
819
+ return ctx.modelRegistry.getAll().find((model: any) => model.provider === parsed.provider);
820
+ }
821
+
822
+ function targetMatchesCurrent(target: string, currentModel: any) {
823
+ const parsed = parseTarget(target);
824
+ if (!parsed || !currentModel) return false;
825
+ if (parsed.provider !== currentModel.provider) return false;
826
+ return !parsed.modelId || parsed.modelId === currentModel.id;
827
+ }
828
+
829
+ /**
830
+ * Returns fallback models ordered by **time-to-recovery** (soonest first).
831
+ *
832
+ * Selection policy (never random, never just "next in list"):
833
+ * 1. Accounts available right now (no active cooldown) win, in deterministic
834
+ * rotation order as a tiebreak.
835
+ * 2. If every account is on cooldown, probe the one with the SHORTEST remaining
836
+ * cooldown first — i.e. the account that will recover soonest — honoring the
837
+ * per-provider probe interval so we don't hammer a still-limited account.
838
+ */
839
+ function findFallbackModels(ctx: any, currentModel: any) {
840
+ const fallbacks = activeFallbacks();
841
+ if (fallbacks.length === 0) return [];
842
+
843
+ const now = Date.now();
844
+ const lastProbe = lastProbeMap();
845
+
846
+ type Scored = { model: any; remaining: number; rotIndex: number; probeReady: boolean };
847
+ const scored: Scored[] = [];
848
+ const seen = new Set<string>();
849
+
850
+ for (let i = 0; i < fallbacks.length; i++) {
851
+ const model = resolveTarget(ctx, fallbacks[i], currentModel);
852
+ if (!model) continue;
853
+ if (model.provider === currentModel?.provider && model.id === currentModel?.id) continue;
854
+ if (isInvalidated(model.provider)) continue; // dead auth — never select
855
+ const key = `${model.provider}/${model.id}`;
856
+ if (seen.has(key)) continue;
857
+ seen.add(key);
858
+
859
+ const exhaustedUntil = exhaustedUntilByProvider.get(model.provider) ?? 0;
860
+ const remaining = Math.max(0, exhaustedUntil - now);
861
+ const probeReady = now - (lastProbe[model.provider] ?? 0) >= config.probeCooldownMs;
862
+ scored.push({ model, remaining, rotIndex: i, probeReady });
863
+ }
864
+ if (scored.length === 0) return [];
865
+
866
+ // (1) Anything available right now → soonest-recovered wins (all remaining=0),
867
+ // deterministic rotation-order tiebreak.
868
+ const availableNow = scored.filter((s) => s.remaining === 0).sort((a, b) => a.rotIndex - b.rotIndex);
869
+ if (availableNow.length > 0) return availableNow.map((s) => s.model);
870
+
871
+ // (2) All exhausted → closest-to-recovery first (shortest remaining cooldown).
872
+ const probeable = scored.filter((s) => s.probeReady);
873
+ const pool = probeable.length > 0 ? probeable : scored;
874
+ return pool.sort((a, b) => a.remaining - b.remaining || a.rotIndex - b.rotIndex).map((s) => s.model);
875
+ }
876
+
877
+ async function switchToFallback(ctx: any, reason: string, cooldownMs = config.cooldownMs) {
878
+ if (!config.enabled) return false;
879
+ const currentModel = ctx.model;
880
+ if (!currentModel) return false;
881
+
882
+ markExhausted(currentModel.provider, cooldownMs);
883
+ const candidates = findFallbackModels(ctx, currentModel);
884
+ if (candidates.length === 0) {
885
+ const cooldowns = [...exhaustedUntilByProvider.entries()]
886
+ .filter(([, until]) => until > Date.now())
887
+ .map(([c, until]) => `${c}: ${formatUntil(until)}`)
888
+ .join(", ");
889
+ ctx.ui.notify(
890
+ `Provider failover: no available fallback after ${currentModel.provider}/${currentModel.id}. ${cooldowns ? `Cooldowns: ${cooldowns}` : "All known accounts may be unauthenticated, invalidated, or same account."}`,
891
+ "warning",
892
+ );
893
+ return false;
894
+ }
895
+
896
+ const from = ref(currentModel.provider, currentModel.id);
897
+ for (const fallback of candidates) {
898
+ const to = ref(fallback.provider, fallback.id);
899
+ const ok = await pi.setModel(fallback);
900
+ if (!ok) {
901
+ // setModel failed → the account has no usable auth right now.
902
+ ctx.ui.notify(`Provider failover: ${to} has no usable auth, dropping from rotation`, "warning");
903
+ markInvalid(fallback.provider, "setModel failed (no usable auth)");
904
+ continue;
905
+ }
906
+ restoreDesiredThinking(); // keep the user's thinking level across the switch
907
+ setLastProbe(fallback.provider);
908
+ currentPromptSwitch = { from, to, reason, at: Date.now() };
909
+ pi.appendEntry("provider-failover", currentPromptSwitch);
910
+ persist({ lastSwitches: [currentPromptSwitch, ...(persistedState.lastSwitches ?? [])].slice(0, 20) });
911
+ ctx.ui.notify(`Provider failover: ${from} → ${to} (${reason})`, "warning");
912
+ return true;
913
+ }
914
+ ctx.ui.notify(`Provider failover: all fallback candidates after ${from} are missing auth or on cooldown`, "warning");
915
+ return false;
916
+ }
917
+
918
+ // ----- pending auto-resume ---------------------------------------------
919
+
920
+ function continuationPrompt(record: SwitchRecord) {
921
+ return config.continuationPrompt.replaceAll("{from}", record.from).replaceAll("{to}", record.to).replaceAll("{reason}", record.reason);
922
+ }
923
+
924
+ function clearPendingContinuation() {
925
+ if (pendingWakeTimer) {
926
+ clearTimeout(pendingWakeTimer);
927
+ pendingWakeTimer = undefined;
928
+ }
929
+ persistedState = { ...persistedState, pendingContinuationPrompt: undefined, pendingSince: undefined, pendingReason: undefined };
930
+ persist();
931
+ }
932
+
933
+ function nextPendingWakeDelayMs() {
934
+ if (!persistedState.pendingContinuationPrompt) return undefined;
935
+ const now = Date.now();
936
+ const lastProbe = lastProbeMap();
937
+ let bestWakeAt = Number.POSITIVE_INFINITY;
938
+ for (const provider of configuredProviders()) {
939
+ if (isInvalidated(provider)) continue;
940
+ const exhaustedUntil = exhaustedUntilByProvider.get(provider) ?? 0;
941
+ if (exhaustedUntil <= now) return 1000;
942
+ const probeDueAt = (lastProbe[provider] ?? 0) + config.probeCooldownMs;
943
+ bestWakeAt = Math.min(bestWakeAt, exhaustedUntil, probeDueAt);
944
+ }
945
+ if (!Number.isFinite(bestWakeAt)) return config.probeCooldownMs;
946
+ return Math.max(1000, Math.min(bestWakeAt - now, 2_147_483_647));
947
+ }
948
+
949
+ function schedulePendingWake(ctx?: any) {
950
+ if (ctx) latestCtx = ctx;
951
+ if (pendingWakeTimer) clearTimeout(pendingWakeTimer);
952
+ const delayMs = nextPendingWakeDelayMs();
953
+ if (delayMs === undefined) return;
954
+ pendingWakeTimer = setTimeout(() => {
955
+ pendingWakeTimer = undefined;
956
+ void attemptPendingResume();
957
+ }, delayMs);
958
+ }
959
+
960
+ function setPendingContinuation(ctx: any, reason: string) {
961
+ const current = ctx.model ? ref(ctx.model.provider, ctx.model.id) : ("unknown/model" as ModelRef);
962
+ const record: SwitchRecord = { from: current, to: "next-available/account" as ModelRef, reason, at: Date.now() };
963
+ persistedState = {
964
+ ...persistedState,
965
+ pendingContinuationPrompt: persistedState.pendingContinuationPrompt || continuationPrompt(record),
966
+ pendingSince: persistedState.pendingSince || Date.now(),
967
+ pendingReason: reason,
968
+ };
969
+ persist();
970
+ schedulePendingWake(ctx);
971
+ const delayMs = nextPendingWakeDelayMs();
972
+ ctx.ui.notify(
973
+ `Provider failover: all accounts appear exhausted. Will automatically probe/resume in ~${Math.ceil((delayMs ?? config.probeCooldownMs) / 1000)}s if this Pi session stays open.`,
974
+ "warning",
975
+ );
976
+ }
977
+
978
+ async function attemptPendingResume() {
979
+ const ctx = latestCtx;
980
+ const prompt = persistedState.pendingContinuationPrompt;
981
+ if (!ctx || !prompt || !config.enabled || !config.autoContinue) return;
982
+ refreshDiscovery();
983
+ pruneCooldowns();
984
+ const candidates = findFallbackModels(ctx, ctx.model);
985
+ if (candidates.length === 0) {
986
+ schedulePendingWake(ctx);
987
+ return;
988
+ }
989
+ for (const candidate of candidates) {
990
+ const to = ref(candidate.provider, candidate.id);
991
+ const ok = await pi.setModel(candidate);
992
+ if (!ok) {
993
+ markInvalid(candidate.provider, "setModel failed on resume");
994
+ continue;
995
+ }
996
+ restoreDesiredThinking(); // keep the user's thinking level across the switch
997
+ setLastProbe(candidate.provider);
998
+ clearPendingContinuation();
999
+ ctx.ui.notify(`Provider failover: resuming pending work on ${to}`, "warning");
1000
+ pi.sendUserMessage(prompt, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
1001
+ return;
1002
+ }
1003
+ schedulePendingWake(ctx);
1004
+ }
1005
+
1006
+ // ----- error classification --------------------------------------------
1007
+
1008
+ function isAuthError(text: string) {
1009
+ if (!text.trim()) return false;
1010
+ if (patternMatch(text, config.ignoreErrorPatterns)) return false;
1011
+ return patternMatch(text, config.authErrorPatterns);
1012
+ }
1013
+
1014
+ function isLimitError(text: string) {
1015
+ if (!text.trim()) return false;
1016
+ if (patternMatch(text, config.ignoreErrorPatterns)) return false;
1017
+ return patternMatch(text, config.limitErrorPatterns);
1018
+ }
1019
+
1020
+ // ----- command ----------------------------------------------------------
1021
+
1022
+ async function handleCommand(args: string, ctx: any) {
1023
+ latestCtx = ctx;
1024
+ const [commandRaw, arg1] = args.trim().split(/\s+/);
1025
+ const command = (commandRaw || "status").toLowerCase();
1026
+
1027
+ if (command === "reload") {
1028
+ config = loadConfig();
1029
+ refreshDiscovery(true);
1030
+ ctx.ui.notify("pi-multi-account: config reloaded and accounts re-discovered", "info");
1031
+ return;
1032
+ }
1033
+ if (command === "rediscover") {
1034
+ const changed = refreshDiscovery(true);
1035
+ ctx.ui.notify(`pi-multi-account: rediscovered accounts${changed ? "" : " (no auth.json change)"}. Rotation: ${rotation.join(" → ") || "none"}`, "info");
1036
+ return;
1037
+ }
1038
+ if (command === "add") {
1039
+ const family = arg1 === "codex" || arg1 === "openai" ? "openai-codex" : "anthropic";
1040
+ const auth = readAuthFile();
1041
+ let n = 2;
1042
+ while (auth[slotId(family, n)] && n <= config.maxAccountsPerProvider) n++;
1043
+ const id = slotId(family, n);
1044
+ syncRegisteredSlots(auth);
1045
+ ctx.ui.notify(`pi-multi-account: run /login ${id} to add a new ${family} account, then /multi-account rediscover`, "info");
1046
+ return;
1047
+ }
1048
+ if (command === "reset") {
1049
+ exhaustedUntilByProvider.clear();
1050
+ currentPromptSwitch = undefined;
1051
+ autoContinuesThisPrompt = 0;
1052
+ if (pendingWakeTimer) {
1053
+ clearTimeout(pendingWakeTimer);
1054
+ pendingWakeTimer = undefined;
1055
+ }
1056
+ persistedState = { stateVersion: STATE_VERSION, exhaustedUntilByProvider: {}, lastProbeAtByProvider: {}, invalidatedByProvider: {}, lastSwitches: [] };
1057
+ invalidatedByProvider.clear();
1058
+ saveState(persistedState);
1059
+ refreshDiscovery(true);
1060
+ ctx.ui.notify("pi-multi-account: cooldowns, invalidations and pending resume reset", "info");
1061
+ return;
1062
+ }
1063
+ if (command === "next") {
1064
+ await switchToFallback(ctx, "manual /multi-account next", 5 * 60 * 1000);
1065
+ return;
1066
+ }
1067
+ if (command === "enable" || command === "disable") {
1068
+ config = { ...config, enabled: command === "enable" };
1069
+ ctx.ui.notify(`pi-multi-account: failover ${config.enabled ? "enabled" : "disabled"} for this Pi process`, "info");
1070
+ return;
1071
+ }
1072
+
1073
+ refreshDiscovery();
1074
+ const current = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
1075
+ const cooldowns = [...exhaustedUntilByProvider.entries()]
1076
+ .filter(([p, until]) => until > Date.now() && !isInvalidated(p))
1077
+ .map(([p, until]) => `${p}: ${formatUntil(until)}`);
1078
+ const invalids = [...invalidatedByProvider.entries()].map(([p, r]) => `${p} (${r.reason.slice(0, 40)})`);
1079
+ ctx.ui.notify(
1080
+ [
1081
+ `pi-multi-account: ${config.enabled ? "enabled" : "disabled"}${config.autoDiscover ? " · auto-discover ON" : " · auto-discover OFF"}`,
1082
+ `Current: ${current}`,
1083
+ `Rotation (${rotation.length}): ${rotation.join(" → ") || "none — log in to an account"}`,
1084
+ `Registered login slots: ${[...registeredSlots].join(", ") || "(base accounts only)"}`,
1085
+ `Cooldowns: ${cooldowns.length ? cooldowns.join(", ") : "none"}`,
1086
+ `Invalidated (need re-login): ${invalids.length ? invalids.join(", ") : "none"}`,
1087
+ `Pending auto-resume: ${persistedState.pendingContinuationPrompt ? `yes (reason: ${persistedState.pendingReason ?? "unknown"})` : "none"}`,
1088
+ `Config: ${CONFIG_PATH}`,
1089
+ `Commands: status | rediscover | add [anthropic|codex] | next | reset | reload | enable | disable`,
1090
+ ].join("\n"),
1091
+ "info",
1092
+ );
1093
+ }
1094
+
1095
+ for (const name of ["multi-account", "provider-failover", "failover"]) {
1096
+ pi.registerCommand(name, { description: "Manage automatic multi-account failover & rotation", handler: handleCommand });
1097
+ }
1098
+
1099
+ // ----- lifecycle hooks --------------------------------------------------
1100
+
1101
+ refreshDiscovery(true);
1102
+
1103
+ pi.on("session_start", async (_event, ctx) => {
1104
+ latestCtx = ctx;
1105
+ refreshDiscovery(true);
1106
+ pruneCooldowns();
1107
+ schedulePendingWake(ctx);
1108
+ ctx.ui.notify(
1109
+ `pi-multi-account loaded (${config.enabled ? "enabled" : "disabled"}). ${rotation.length} account(s) in rotation. Config: ${CONFIG_PATH}`,
1110
+ "info",
1111
+ );
1112
+ });
1113
+
1114
+ pi.on("session_shutdown", async () => {
1115
+ if (pendingWakeTimer) {
1116
+ clearTimeout(pendingWakeTimer);
1117
+ pendingWakeTimer = undefined;
1118
+ }
1119
+ });
1120
+
1121
+ pi.on("agent_start", async () => {
1122
+ currentPromptSwitch = undefined;
1123
+ autoContinuesThisPrompt = 0;
1124
+ lastErrorText = "";
1125
+ captureDesiredThinking(); // remember the level BEFORE any failover can clamp it
1126
+ refreshDiscovery(); // cheap: only re-scans when auth.json changed (new /login)
1127
+ });
1128
+
1129
+ pi.on("after_provider_response", async (event, ctx) => {
1130
+ latestCtx = ctx;
1131
+ if (!config.enabled) return;
1132
+ const status = (event as any).status;
1133
+ if (status === 401) {
1134
+ // Authorization is dead → drop this account, then move on.
1135
+ if (ctx.model) markInvalid(ctx.model.provider, `HTTP 401`);
1136
+ await switchToFallback(ctx, "HTTP 401 (auth invalid)");
1137
+ return;
1138
+ }
1139
+ if (status !== 429 && status !== 402 && status !== 403) return;
1140
+ const cooldownMs = cooldownFromHeaders((event as any).headers ?? {}) ?? config.cooldownMs;
1141
+ await switchToFallback(ctx, `HTTP ${status}`, cooldownMs);
1142
+ });
1143
+
1144
+ pi.on("message_end", async (event, ctx) => {
1145
+ latestCtx = ctx;
1146
+ const message = (event as any).message;
1147
+ if (message?.role !== "assistant" || message.stopReason !== "error") return;
1148
+ const errorText = typeof message.errorMessage === "string" ? message.errorMessage : "";
1149
+ lastErrorText = errorText;
1150
+ if (currentPromptSwitch) return;
1151
+ if (isAuthError(errorText)) {
1152
+ if (ctx.model) markInvalid(ctx.model.provider, errorText.slice(0, 60));
1153
+ await switchToFallback(ctx, `auth invalid: ${errorText.slice(0, 100)}`);
1154
+ return;
1155
+ }
1156
+ if (isLimitError(errorText)) {
1157
+ await switchToFallback(ctx, `assistant error: ${errorText.slice(0, 120)}`, cooldownFromErrorText(errorText) ?? config.cooldownMs);
1158
+ }
1159
+ });
1160
+
1161
+ pi.on("agent_end", async (event, ctx) => {
1162
+ latestCtx = ctx;
1163
+ if (!config.enabled || !config.autoContinue) return;
1164
+ const errorText = lastErrorText || getAssistantErrorText((event as any).messages ?? []);
1165
+ if (isAuthError(errorText) && ctx.model) markInvalid(ctx.model.provider, errorText.slice(0, 60));
1166
+ if (!isLimitError(errorText) && !isAuthError(errorText)) return;
1167
+ if (autoContinuesThisPrompt >= config.maxAutoContinuesPerPrompt) return;
1168
+
1169
+ if (!currentPromptSwitch) {
1170
+ const reason = `agent ended with provider limit: ${errorText.slice(0, 120)}`;
1171
+ const switched = await switchToFallback(ctx, reason, cooldownFromErrorText(errorText) ?? config.cooldownMs);
1172
+ if (!switched && !currentPromptSwitch) {
1173
+ setPendingContinuation(ctx, reason);
1174
+ return;
1175
+ }
1176
+ }
1177
+
1178
+ if (currentPromptSwitch) {
1179
+ autoContinuesThisPrompt++;
1180
+ const prompt = continuationPrompt(currentPromptSwitch);
1181
+ pi.sendUserMessage(prompt, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
1182
+ }
1183
+ });
1184
+ }