@zgltyq/pi-provider-claude 1.2.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 +14 -0
- package/index.ts +84 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -91,6 +91,20 @@ the assistant message (`stopReason: "error"`) via `message_end`, including Claud
|
|
|
91
91
|
is hit, the turn errors once, the pool flips (with a UI notification), and you just
|
|
92
92
|
resend your message to continue on the other account.
|
|
93
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
|
+
|
|
94
108
|
It is **inert** unless `<agentDir>/claude-pool.json` exists with ≥2 accounts and
|
|
95
109
|
`"enabled" !== false`.
|
|
96
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 });
|
|
@@ -363,9 +371,12 @@ function poolSelectActive(): void {
|
|
|
363
371
|
}
|
|
364
372
|
}
|
|
365
373
|
|
|
366
|
-
|
|
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> {
|
|
367
377
|
const acct = poolAccounts[i];
|
|
368
|
-
if (!acct
|
|
378
|
+
if (!acct) return false;
|
|
379
|
+
if (!force && acct.expires > Date.now() + POOL_REFRESH_BUFFER_MS) return true;
|
|
369
380
|
try {
|
|
370
381
|
const oauth = (await import("@earendil-works/pi-ai/oauth")) as {
|
|
371
382
|
refreshAnthropicToken: (
|
|
@@ -377,11 +388,39 @@ async function poolEnsureFresh(i: number): Promise<void> {
|
|
|
377
388
|
acct.expires = next.expires;
|
|
378
389
|
if (next.refresh) acct.refresh = next.refresh;
|
|
379
390
|
persistPool();
|
|
380
|
-
poolLog(`refreshed ${acct.label}`);
|
|
391
|
+
poolLog(`refreshed ${acct.label} (exp ${new Date(acct.expires).toISOString()})`);
|
|
392
|
+
return true;
|
|
381
393
|
} catch (e) {
|
|
382
394
|
poolLog(
|
|
383
395
|
`refresh FAILED ${acct.label}: ${(e as Error)?.message ?? String(e)}`,
|
|
384
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;
|
|
385
424
|
}
|
|
386
425
|
}
|
|
387
426
|
|
|
@@ -415,6 +454,13 @@ function poolMarkRateLimitedUntil(until: number): void {
|
|
|
415
454
|
const POOL_LIMIT_RE =
|
|
416
455
|
/\b429\b|\b529\b|rate[ _-]?limit|usage[ _-]?limit|overloaded_error|quota/i;
|
|
417
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
|
+
|
|
418
464
|
// Claude subscription cap messages often carry a reset epoch after a pipe,
|
|
419
465
|
// e.g. "Claude AI usage limit reached|1750000000".
|
|
420
466
|
function poolParseResetEpoch(message: string): number | undefined {
|
|
@@ -544,7 +590,9 @@ function setupPool(pi: ExtensionAPI): void {
|
|
|
544
590
|
|
|
545
591
|
const prep = async () => {
|
|
546
592
|
poolSelectActive();
|
|
547
|
-
|
|
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();
|
|
548
596
|
};
|
|
549
597
|
pi.on("session_start", async () => {
|
|
550
598
|
await prep();
|
|
@@ -553,6 +601,20 @@ function setupPool(pi: ExtensionAPI): void {
|
|
|
553
601
|
await prep();
|
|
554
602
|
});
|
|
555
603
|
|
|
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
|
+
|
|
556
618
|
// PRIMARY rate/usage-cap detector: provider errors surface on the assistant
|
|
557
619
|
// message (stopReason "error"), not via after_provider_response (the SDK
|
|
558
620
|
// throws on 429/529 before pi-ai's onResponse callback runs).
|
|
@@ -577,6 +639,23 @@ function setupPool(pi: ExtensionAPI): void {
|
|
|
577
639
|
)
|
|
578
640
|
return undefined;
|
|
579
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
|
+
|
|
580
659
|
if (!poolHandleErrorMessage(msg.errorMessage)) return undefined;
|
|
581
660
|
const next = poolAccounts[poolActive]?.label;
|
|
582
661
|
if (next !== prev) {
|
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",
|