@zgltyq/pi-provider-claude 1.1.0 → 1.3.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/README.md +27 -0
- package/index.ts +165 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -78,6 +78,33 @@ fail over between them: when the active account hits its rate / usage cap (429/5
|
|
|
78
78
|
it's put on cooldown and the next turn transparently continues on the other account.
|
|
79
79
|
Subscription billing is preserved (Pi detects the OAuth path from the token shape).
|
|
80
80
|
|
|
81
|
+
**Priority:** array order in `claude-pool.json` IS the priority. `accounts[0]` is the
|
|
82
|
+
primary — failover is non-sticky, so as soon as the primary's cooldown expires the
|
|
83
|
+
pool returns to it automatically. Put your preferred account first.
|
|
84
|
+
|
|
85
|
+
**Detection (v1.2.0):** pi-ai's anthropic transport only emits
|
|
86
|
+
`after_provider_response` for *successful* requests — the Anthropic SDK throws on
|
|
87
|
+
429/529 before that callback runs, so header-based detection alone never fires.
|
|
88
|
+
Since v1.2.0 the primary detector pattern-matches the provider error that lands on
|
|
89
|
+
the assistant message (`stopReason: "error"`) via `message_end`, including Claude's
|
|
90
|
+
`"usage limit reached|<epoch>"` reset timestamps for accurate cooldowns. When a cap
|
|
91
|
+
is hit, the turn errors once, the pool flips (with a UI notification), and you just
|
|
92
|
+
resend your message to continue on the other account.
|
|
93
|
+
|
|
94
|
+
**Background token refresh (v1.3.0):** OAuth access tokens are short-lived. An
|
|
95
|
+
account that sat idle (e.g. your fallback) would have a dead access token by the
|
|
96
|
+
time failover switched to it — producing a `401 authentication_error`. v1.3.0 adds:
|
|
97
|
+
|
|
98
|
+
- A `setInterval` keep-warm loop that refreshes **every** pooled account before it
|
|
99
|
+
expires, even when idle and not the active account (the timer is `unref()`'d so
|
|
100
|
+
it never blocks CLI commands like `pi update` from exiting).
|
|
101
|
+
- A wider 5-minute pre-expiry refresh buffer, and a full-pool refresh on
|
|
102
|
+
`session_start` / `before_agent_start` (not just the active account).
|
|
103
|
+
- Reactive 401 handling: if the server rejects the active token anyway, it is
|
|
104
|
+
force-refreshed in place (no account switch) so the resend succeeds. If the
|
|
105
|
+
refresh token itself was revoked, you're told to re-run `/login` +
|
|
106
|
+
`/claude-pool-add <label>`.
|
|
107
|
+
|
|
81
108
|
It is **inert** unless `<agentDir>/claude-pool.json` exists with ≥2 accounts and
|
|
82
109
|
`"enabled" !== false`.
|
|
83
110
|
|
package/index.ts
CHANGED
|
@@ -291,12 +291,20 @@ const POOL_PATH = join(
|
|
|
291
291
|
process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"),
|
|
292
292
|
"claude-pool.json",
|
|
293
293
|
);
|
|
294
|
-
|
|
294
|
+
// Refresh an access token this long BEFORE it actually expires, so we never
|
|
295
|
+
// hand a request a token that's about to die. Generous because OAuth access
|
|
296
|
+
// tokens are short-lived and the failover target may have sat idle for days.
|
|
297
|
+
const POOL_REFRESH_BUFFER_MS = 5 * 60_000;
|
|
295
298
|
const POOL_DEFAULT_COOLDOWN_MS = 5 * 60_000;
|
|
299
|
+
// Background keep-warm cadence: re-check every account on this interval even
|
|
300
|
+
// when idle / not the active one, so an account is never stale when we switch.
|
|
301
|
+
const POOL_BG_REFRESH_INTERVAL_MS = 4 * 60_000;
|
|
296
302
|
|
|
297
303
|
let poolAccounts: PoolAccount[] = [];
|
|
298
304
|
let poolActive = 0;
|
|
299
305
|
let poolCooldownUntil: number[] = [];
|
|
306
|
+
let poolBgTimer: ReturnType<typeof setInterval> | undefined;
|
|
307
|
+
let poolRefreshInFlight = false;
|
|
300
308
|
|
|
301
309
|
function poolLog(msg: string): void {
|
|
302
310
|
writeDebugLog({ stage: "pool", time: new Date().toISOString(), msg });
|
|
@@ -336,7 +344,10 @@ function persistPool(): void {
|
|
|
336
344
|
|
|
337
345
|
function poolSelectActive(): void {
|
|
338
346
|
const now = Date.now();
|
|
339
|
-
|
|
347
|
+
// Priority = array order: always prefer the LOWEST-index account that is
|
|
348
|
+
// not cooling down (accounts[0] is the primary, e.g. "work"). This makes
|
|
349
|
+
// failover non-sticky: as soon as the primary's cooldown expires, we
|
|
350
|
+
// return to it instead of staying on the fallback account.
|
|
340
351
|
let pick = -1;
|
|
341
352
|
for (let i = 0; i < poolAccounts.length; i++) {
|
|
342
353
|
if (poolCooldownUntil[i] <= now) {
|
|
@@ -360,9 +371,12 @@ function poolSelectActive(): void {
|
|
|
360
371
|
}
|
|
361
372
|
}
|
|
362
373
|
|
|
363
|
-
|
|
374
|
+
// Low-level refresh of account `i`. Returns true on success. When `force` is
|
|
375
|
+
// false it skips accounts whose token is still comfortably valid.
|
|
376
|
+
async function poolRefreshAccount(i: number, force: boolean): Promise<boolean> {
|
|
364
377
|
const acct = poolAccounts[i];
|
|
365
|
-
if (!acct
|
|
378
|
+
if (!acct) return false;
|
|
379
|
+
if (!force && acct.expires > Date.now() + POOL_REFRESH_BUFFER_MS) return true;
|
|
366
380
|
try {
|
|
367
381
|
const oauth = (await import("@earendil-works/pi-ai/oauth")) as {
|
|
368
382
|
refreshAnthropicToken: (
|
|
@@ -374,11 +388,39 @@ async function poolEnsureFresh(i: number): Promise<void> {
|
|
|
374
388
|
acct.expires = next.expires;
|
|
375
389
|
if (next.refresh) acct.refresh = next.refresh;
|
|
376
390
|
persistPool();
|
|
377
|
-
poolLog(`refreshed ${acct.label}`);
|
|
391
|
+
poolLog(`refreshed ${acct.label} (exp ${new Date(acct.expires).toISOString()})`);
|
|
392
|
+
return true;
|
|
378
393
|
} catch (e) {
|
|
379
394
|
poolLog(
|
|
380
395
|
`refresh FAILED ${acct.label}: ${(e as Error)?.message ?? String(e)}`,
|
|
381
396
|
);
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Refresh account `i` only if it is near (or past) expiry.
|
|
402
|
+
async function poolEnsureFresh(i: number): Promise<void> {
|
|
403
|
+
await poolRefreshAccount(i, false);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Force a refresh of account `i` regardless of expiry (used on a 401, when the
|
|
407
|
+
// server rejected the token even though we thought it was still valid).
|
|
408
|
+
async function poolForceRefresh(i: number): Promise<boolean> {
|
|
409
|
+
return poolRefreshAccount(i, true);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Background keep-warm: refresh EVERY pooled account whose token is near expiry,
|
|
413
|
+
// not just the active one. This is what guarantees the failover target is never
|
|
414
|
+
// stale when we switch to it after it sat unused for a long time.
|
|
415
|
+
async function poolRefreshAll(): Promise<void> {
|
|
416
|
+
if (poolRefreshInFlight) return; // never overlap refresh sweeps
|
|
417
|
+
poolRefreshInFlight = true;
|
|
418
|
+
try {
|
|
419
|
+
for (let i = 0; i < poolAccounts.length; i++) {
|
|
420
|
+
await poolEnsureFresh(i);
|
|
421
|
+
}
|
|
422
|
+
} finally {
|
|
423
|
+
poolRefreshInFlight = false;
|
|
382
424
|
}
|
|
383
425
|
}
|
|
384
426
|
|
|
@@ -391,6 +433,10 @@ function poolMarkRateLimited(headers?: Record<string, string>): void {
|
|
|
391
433
|
const n = Number(ra);
|
|
392
434
|
if (Number.isFinite(n)) until = n > 1e9 ? n * 1000 : now + n * 1000; // epoch-seconds vs delta-seconds
|
|
393
435
|
}
|
|
436
|
+
poolMarkRateLimitedUntil(until);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function poolMarkRateLimitedUntil(until: number): void {
|
|
394
440
|
poolCooldownUntil[poolActive] = until;
|
|
395
441
|
poolLog(
|
|
396
442
|
`rate-limited ${poolAccounts[poolActive].label} until ${new Date(until).toISOString()}`,
|
|
@@ -398,6 +444,41 @@ function poolMarkRateLimited(headers?: Record<string, string>): void {
|
|
|
398
444
|
poolSelectActive();
|
|
399
445
|
}
|
|
400
446
|
|
|
447
|
+
// Rate/usage-cap detection from ERROR MESSAGES.
|
|
448
|
+
//
|
|
449
|
+
// pi-ai's anthropic provider only invokes onResponse (→ after_provider_response)
|
|
450
|
+
// for SUCCESSFUL requests — on a 429/529 the Anthropic SDK throws before that
|
|
451
|
+
// callback, so the header-based hook above never sees rate limits in practice.
|
|
452
|
+
// The thrown error instead lands on the assistant message as stopReason:"error"
|
|
453
|
+
// + errorMessage, which flows through message_end. We pattern-match it there.
|
|
454
|
+
const POOL_LIMIT_RE =
|
|
455
|
+
/\b429\b|\b529\b|rate[ _-]?limit|usage[ _-]?limit|overloaded_error|quota/i;
|
|
456
|
+
|
|
457
|
+
// Expired / rejected credential detection (401). When the active account's
|
|
458
|
+
// token is rejected we force-refresh it in place so the resend succeeds — this
|
|
459
|
+
// is the symptom of switching to an account that sat idle until its access
|
|
460
|
+
// token died. Anchored to avoid colliding with the 429 message.
|
|
461
|
+
const POOL_AUTH_RE =
|
|
462
|
+
/authentication_error|invalid authentication credentials|\b401\b/i;
|
|
463
|
+
|
|
464
|
+
// Claude subscription cap messages often carry a reset epoch after a pipe,
|
|
465
|
+
// e.g. "Claude AI usage limit reached|1750000000".
|
|
466
|
+
function poolParseResetEpoch(message: string): number | undefined {
|
|
467
|
+
const m = message.match(/\|(\d{9,13})\b/);
|
|
468
|
+
if (!m) return undefined;
|
|
469
|
+
const n = Number(m[1]);
|
|
470
|
+
return n > 1e12 ? n : n * 1000;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Returns true if the error was a rate/usage cap and the pool reacted.
|
|
474
|
+
function poolHandleErrorMessage(errorMessage: string): boolean {
|
|
475
|
+
if (!POOL_LIMIT_RE.test(errorMessage)) return false;
|
|
476
|
+
const until =
|
|
477
|
+
poolParseResetEpoch(errorMessage) ?? Date.now() + POOL_DEFAULT_COOLDOWN_MS;
|
|
478
|
+
poolMarkRateLimitedUntil(until);
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
|
|
401
482
|
// Read the current single-slot anthropic OAuth creds from auth.json.
|
|
402
483
|
function readAnthropicAuth():
|
|
403
484
|
| { refresh: string; access: string; expires: number }
|
|
@@ -509,7 +590,9 @@ function setupPool(pi: ExtensionAPI): void {
|
|
|
509
590
|
|
|
510
591
|
const prep = async () => {
|
|
511
592
|
poolSelectActive();
|
|
512
|
-
|
|
593
|
+
// Keep ALL accounts warm, not just the active one, so the failover target
|
|
594
|
+
// is ready the instant we switch to it.
|
|
595
|
+
await poolRefreshAll();
|
|
513
596
|
};
|
|
514
597
|
pi.on("session_start", async () => {
|
|
515
598
|
await prep();
|
|
@@ -518,7 +601,82 @@ function setupPool(pi: ExtensionAPI): void {
|
|
|
518
601
|
await prep();
|
|
519
602
|
});
|
|
520
603
|
|
|
521
|
-
//
|
|
604
|
+
// Background keep-warm loop: refresh every account before it expires even
|
|
605
|
+
// while idle / not active. This is the core fix — the failover target is kept
|
|
606
|
+
// fresh continuously so it's never expired when we switch to it.
|
|
607
|
+
//
|
|
608
|
+
// unref() is CRITICAL: an active timer handle keeps the Node event loop alive
|
|
609
|
+
// and would prevent short-lived CLI commands (e.g. `pi update`) from exiting.
|
|
610
|
+
// We also do NOT fire an immediate refresh here — interactive sessions warm
|
|
611
|
+
// via session_start; firing at module load would add network I/O to the CLI
|
|
612
|
+
// path. The first tick lands one interval later, only in long-lived sessions.
|
|
613
|
+
poolBgTimer = setInterval(() => {
|
|
614
|
+
void poolRefreshAll();
|
|
615
|
+
}, POOL_BG_REFRESH_INTERVAL_MS);
|
|
616
|
+
if (typeof poolBgTimer.unref === "function") poolBgTimer.unref();
|
|
617
|
+
|
|
618
|
+
// PRIMARY rate/usage-cap detector: provider errors surface on the assistant
|
|
619
|
+
// message (stopReason "error"), not via after_provider_response (the SDK
|
|
620
|
+
// throws on 429/529 before pi-ai's onResponse callback runs).
|
|
621
|
+
pi.on("message_end", async (event, ctx) => {
|
|
622
|
+
try {
|
|
623
|
+
const model = ctx.model;
|
|
624
|
+
if (
|
|
625
|
+
!model ||
|
|
626
|
+
model.provider !== "anthropic" ||
|
|
627
|
+
!ctx.modelRegistry.isUsingOAuth(model)
|
|
628
|
+
)
|
|
629
|
+
return undefined;
|
|
630
|
+
const msg = event.message as {
|
|
631
|
+
role?: string;
|
|
632
|
+
stopReason?: string;
|
|
633
|
+
errorMessage?: string;
|
|
634
|
+
};
|
|
635
|
+
if (
|
|
636
|
+
msg.role !== "assistant" ||
|
|
637
|
+
msg.stopReason !== "error" ||
|
|
638
|
+
typeof msg.errorMessage !== "string"
|
|
639
|
+
)
|
|
640
|
+
return undefined;
|
|
641
|
+
const prev = poolAccounts[poolActive]?.label;
|
|
642
|
+
|
|
643
|
+
// 401 / expired-credential path: the server rejected the ACTIVE token.
|
|
644
|
+
// Force-refresh it in place (don't switch accounts) so the resend works.
|
|
645
|
+
if (
|
|
646
|
+
POOL_AUTH_RE.test(msg.errorMessage) &&
|
|
647
|
+
!POOL_LIMIT_RE.test(msg.errorMessage)
|
|
648
|
+
) {
|
|
649
|
+
const ok = await poolForceRefresh(poolActive);
|
|
650
|
+
ctx.ui.notify(
|
|
651
|
+
ok
|
|
652
|
+
? `Claude account "${prev}" had an expired token — refreshed it. Resend your message to continue.`
|
|
653
|
+
: `Claude account "${prev}" token is invalid and refresh FAILED — its refresh token was likely revoked. Run /login anthropic then /claude-pool-add ${prev}.`,
|
|
654
|
+
ok ? "info" : "warning",
|
|
655
|
+
);
|
|
656
|
+
return undefined;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (!poolHandleErrorMessage(msg.errorMessage)) return undefined;
|
|
660
|
+
const next = poolAccounts[poolActive]?.label;
|
|
661
|
+
if (next !== prev) {
|
|
662
|
+
await poolEnsureFresh(poolActive);
|
|
663
|
+
ctx.ui.notify(
|
|
664
|
+
`Claude account "${prev}" hit its limit → switched to "${next}". Resend your message to continue.`,
|
|
665
|
+
"warning",
|
|
666
|
+
);
|
|
667
|
+
} else {
|
|
668
|
+
ctx.ui.notify(
|
|
669
|
+
`Claude account "${prev}" hit its limit and all pooled accounts are cooling down — retrying on the one that frees up soonest.`,
|
|
670
|
+
"warning",
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
} catch {
|
|
674
|
+
/* detection must never break the message path */
|
|
675
|
+
}
|
|
676
|
+
return undefined;
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// Secondary detector (kept for transports that DO surface response headers).
|
|
522
680
|
pi.on("after_provider_response", (event: unknown) => {
|
|
523
681
|
try {
|
|
524
682
|
const e = event as {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zgltyq/pi-provider-claude",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Claude (Anthropic OAuth/subscription) compatibility layer for pi-coding-agent — keeps ALL extension tools usable under the Claude subscription by mapping unknown flat tool names to mcp__pi__<name> on the wire instead of dropping them, plus optional multi-account failover. Self-contained fork of the tool half of @benvargas/pi-claude-code-use.",
|
|
5
5
|
"author": "Leechael",
|
|
6
6
|
"license": "MIT",
|