@zgltyq/pi-provider-claude 1.1.0 → 1.2.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 (3) hide show
  1. package/README.md +13 -0
  2. package/index.ts +81 -2
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -78,6 +78,19 @@ 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
+
81
94
  It is **inert** unless `<agentDir>/claude-pool.json` exists with ≥2 accounts and
82
95
  `"enabled" !== false`.
83
96
 
package/index.ts CHANGED
@@ -336,7 +336,10 @@ function persistPool(): void {
336
336
 
337
337
  function poolSelectActive(): void {
338
338
  const now = Date.now();
339
- if (poolCooldownUntil[poolActive] <= now) return; // active still usable
339
+ // Priority = array order: always prefer the LOWEST-index account that is
340
+ // not cooling down (accounts[0] is the primary, e.g. "work"). This makes
341
+ // failover non-sticky: as soon as the primary's cooldown expires, we
342
+ // return to it instead of staying on the fallback account.
340
343
  let pick = -1;
341
344
  for (let i = 0; i < poolAccounts.length; i++) {
342
345
  if (poolCooldownUntil[i] <= now) {
@@ -391,6 +394,10 @@ function poolMarkRateLimited(headers?: Record<string, string>): void {
391
394
  const n = Number(ra);
392
395
  if (Number.isFinite(n)) until = n > 1e9 ? n * 1000 : now + n * 1000; // epoch-seconds vs delta-seconds
393
396
  }
397
+ poolMarkRateLimitedUntil(until);
398
+ }
399
+
400
+ function poolMarkRateLimitedUntil(until: number): void {
394
401
  poolCooldownUntil[poolActive] = until;
395
402
  poolLog(
396
403
  `rate-limited ${poolAccounts[poolActive].label} until ${new Date(until).toISOString()}`,
@@ -398,6 +405,34 @@ function poolMarkRateLimited(headers?: Record<string, string>): void {
398
405
  poolSelectActive();
399
406
  }
400
407
 
408
+ // Rate/usage-cap detection from ERROR MESSAGES.
409
+ //
410
+ // pi-ai's anthropic provider only invokes onResponse (→ after_provider_response)
411
+ // for SUCCESSFUL requests — on a 429/529 the Anthropic SDK throws before that
412
+ // callback, so the header-based hook above never sees rate limits in practice.
413
+ // The thrown error instead lands on the assistant message as stopReason:"error"
414
+ // + errorMessage, which flows through message_end. We pattern-match it there.
415
+ const POOL_LIMIT_RE =
416
+ /\b429\b|\b529\b|rate[ _-]?limit|usage[ _-]?limit|overloaded_error|quota/i;
417
+
418
+ // Claude subscription cap messages often carry a reset epoch after a pipe,
419
+ // e.g. "Claude AI usage limit reached|1750000000".
420
+ function poolParseResetEpoch(message: string): number | undefined {
421
+ const m = message.match(/\|(\d{9,13})\b/);
422
+ if (!m) return undefined;
423
+ const n = Number(m[1]);
424
+ return n > 1e12 ? n : n * 1000;
425
+ }
426
+
427
+ // Returns true if the error was a rate/usage cap and the pool reacted.
428
+ function poolHandleErrorMessage(errorMessage: string): boolean {
429
+ if (!POOL_LIMIT_RE.test(errorMessage)) return false;
430
+ const until =
431
+ poolParseResetEpoch(errorMessage) ?? Date.now() + POOL_DEFAULT_COOLDOWN_MS;
432
+ poolMarkRateLimitedUntil(until);
433
+ return true;
434
+ }
435
+
401
436
  // Read the current single-slot anthropic OAuth creds from auth.json.
402
437
  function readAnthropicAuth():
403
438
  | { refresh: string; access: string; expires: number }
@@ -518,7 +553,51 @@ function setupPool(pi: ExtensionAPI): void {
518
553
  await prep();
519
554
  });
520
555
 
521
- // Observe rate/usage-cap responses cooldown the active account + flip.
556
+ // PRIMARY rate/usage-cap detector: provider errors surface on the assistant
557
+ // message (stopReason "error"), not via after_provider_response (the SDK
558
+ // throws on 429/529 before pi-ai's onResponse callback runs).
559
+ pi.on("message_end", async (event, ctx) => {
560
+ try {
561
+ const model = ctx.model;
562
+ if (
563
+ !model ||
564
+ model.provider !== "anthropic" ||
565
+ !ctx.modelRegistry.isUsingOAuth(model)
566
+ )
567
+ return undefined;
568
+ const msg = event.message as {
569
+ role?: string;
570
+ stopReason?: string;
571
+ errorMessage?: string;
572
+ };
573
+ if (
574
+ msg.role !== "assistant" ||
575
+ msg.stopReason !== "error" ||
576
+ typeof msg.errorMessage !== "string"
577
+ )
578
+ return undefined;
579
+ const prev = poolAccounts[poolActive]?.label;
580
+ if (!poolHandleErrorMessage(msg.errorMessage)) return undefined;
581
+ const next = poolAccounts[poolActive]?.label;
582
+ if (next !== prev) {
583
+ await poolEnsureFresh(poolActive);
584
+ ctx.ui.notify(
585
+ `Claude account "${prev}" hit its limit → switched to "${next}". Resend your message to continue.`,
586
+ "warning",
587
+ );
588
+ } else {
589
+ ctx.ui.notify(
590
+ `Claude account "${prev}" hit its limit and all pooled accounts are cooling down — retrying on the one that frees up soonest.`,
591
+ "warning",
592
+ );
593
+ }
594
+ } catch {
595
+ /* detection must never break the message path */
596
+ }
597
+ return undefined;
598
+ });
599
+
600
+ // Secondary detector (kept for transports that DO surface response headers).
522
601
  pi.on("after_provider_response", (event: unknown) => {
523
602
  try {
524
603
  const e = event as {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgltyq/pi-provider-claude",
3
- "version": "1.1.0",
3
+ "version": "1.2.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",