@zgltyq/pi-provider-claude 1.0.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.
- package/README.md +60 -1
- package/harvest-claude-pool.sh +26 -0
- package/index.ts +393 -1
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -71,12 +71,71 @@ PI_CLAUDE_PROVIDER_DEBUG_LOG=/tmp/claude-provider.log pi
|
|
|
71
71
|
In `/tmp/claude-provider.log`, the `"after"` payload's `tools[]` should show your
|
|
72
72
|
extension tools as `mcp__pi__<name>` and **nothing dropped**.
|
|
73
73
|
|
|
74
|
+
## Multi-account failover (opt-in)
|
|
75
|
+
|
|
76
|
+
If you have **two subscription accounts** (e.g. personal + work), the extension can
|
|
77
|
+
fail over between them: when the active account hits its rate / usage cap (429/529),
|
|
78
|
+
it's put on cooldown and the next turn transparently continues on the other account.
|
|
79
|
+
Subscription billing is preserved (Pi detects the OAuth path from the token shape).
|
|
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
|
+
It is **inert** unless `<agentDir>/claude-pool.json` exists with ≥2 accounts and
|
|
95
|
+
`"enabled" !== false`.
|
|
96
|
+
|
|
97
|
+
### Enroll accounts with the native `/login` (recommended)
|
|
98
|
+
|
|
99
|
+
`/login anthropic` only keeps ONE credential (a second login overwrites the first),
|
|
100
|
+
so the extension adds a command to snapshot each login into the pool:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
/login anthropic → sign in with account A
|
|
104
|
+
/claude-pool-add personal → stash account A in the pool
|
|
105
|
+
/login anthropic → sign in with account B (overwrites auth.json — fine)
|
|
106
|
+
/claude-pool-add work → stash account B in the pool
|
|
107
|
+
/claude-pool-status → verify both are present
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Restart, and failover activates. `/claude-pool-add <label>` only writes
|
|
111
|
+
`claude-pool.json` (it never touches the request path).
|
|
112
|
+
|
|
113
|
+
### Alternative: harvest via separate profiles
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
PI_CODING_AGENT_DIR=~/.pi-personal pi # /login anthropic (personal)
|
|
117
|
+
PI_CODING_AGENT_DIR=~/.pi-work pi # /login anthropic (work)
|
|
118
|
+
./harvest-claude-pool.sh # writes ~/.pi/agent/claude-pool.json
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`claude-pool.json` shape:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{ "enabled": true, "accounts": [
|
|
125
|
+
{ "label": "personal", "refresh": "...", "access": "...", "expires": 0 },
|
|
126
|
+
{ "label": "work", "refresh": "...", "access": "...", "expires": 0 }
|
|
127
|
+
] }
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Kill switch: `PI_CLAUDE_PROVIDER_POOL_DISABLE=1` (or `"enabled": false`).
|
|
131
|
+
|
|
74
132
|
## Environment variables
|
|
75
133
|
|
|
76
134
|
| Variable | Effect |
|
|
77
135
|
|---|---|
|
|
78
|
-
| `PI_CLAUDE_PROVIDER_DEBUG_LOG=/path` | Append `before`/`after`
|
|
136
|
+
| `PI_CLAUDE_PROVIDER_DEBUG_LOG=/path` | Append `before`/`after`/`pool` events for debugging. |
|
|
79
137
|
| `PI_CLAUDE_PROVIDER_DISABLE=1` | Pass all tools through with flat names (no renaming). Debug escape hatch. |
|
|
138
|
+
| `PI_CLAUDE_PROVIDER_POOL_DISABLE=1` | Disable multi-account failover even if `claude-pool.json` exists. |
|
|
80
139
|
|
|
81
140
|
## How it differs from `@benvargas/pi-claude-code-use`
|
|
82
141
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Harvest both accounts' Anthropic OAuth creds (from two PI_CODING_AGENT_DIR
|
|
3
|
+
# profiles) into ~/.pi/agent/claude-pool.json for pi-provider-claude failover.
|
|
4
|
+
#
|
|
5
|
+
# Prereq — log into each profile ONCE (interactive), in separate terminals:
|
|
6
|
+
# PI_CODING_AGENT_DIR=~/.pi-personal pi # then: /login anthropic (personal)
|
|
7
|
+
# PI_CODING_AGENT_DIR=~/.pi-work pi # then: /login anthropic (work)
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
P_PERSONAL="${1:-$HOME/.pi-personal/auth.json}"
|
|
10
|
+
P_WORK="${2:-$HOME/.pi-work/auth.json}"
|
|
11
|
+
OUT="$HOME/.pi/agent/claude-pool.json"
|
|
12
|
+
python3 - "$P_PERSONAL" "$P_WORK" "$OUT" <<'PY'
|
|
13
|
+
import json,sys,os
|
|
14
|
+
def block(path,label):
|
|
15
|
+
if not os.path.exists(path): sys.exit(f"missing {path} — run /login anthropic in that profile first")
|
|
16
|
+
a=json.load(open(path)).get("anthropic")
|
|
17
|
+
if not a or "access" not in a or "refresh" not in a:
|
|
18
|
+
sys.exit(f"{path}: no anthropic OAuth creds (did you /login anthropic with a subscription account?)")
|
|
19
|
+
return {"label":label,"refresh":a["refresh"],"access":a["access"],"expires":a.get("expires",0)}
|
|
20
|
+
personal, work, out = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
21
|
+
pool={"enabled":True,"accounts":[block(personal,"personal"),block(work,"work")]}
|
|
22
|
+
os.makedirs(os.path.dirname(out),exist_ok=True)
|
|
23
|
+
with open(out,"w") as f: json.dump(pool,f,indent=2)
|
|
24
|
+
os.chmod(out,0o600)
|
|
25
|
+
print(f"wrote {out} with {len(pool['accounts'])} accounts:",", ".join(x['label'] for x in pool['accounts']))
|
|
26
|
+
PY
|
package/index.ts
CHANGED
|
@@ -36,7 +36,14 @@
|
|
|
36
36
|
* PI_CLAUDE_PROVIDER_DISABLE=1 → pass everything through flat (debug)
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
appendFileSync,
|
|
41
|
+
existsSync,
|
|
42
|
+
readFileSync,
|
|
43
|
+
writeFileSync,
|
|
44
|
+
} from "node:fs";
|
|
45
|
+
import { homedir } from "node:os";
|
|
46
|
+
import { join } from "node:path";
|
|
40
47
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
41
48
|
|
|
42
49
|
// Prefix used to disguise flat tools as MCP tools. Kept short so
|
|
@@ -252,6 +259,387 @@ function writeDebugLog(payload: unknown): void {
|
|
|
252
259
|
}
|
|
253
260
|
}
|
|
254
261
|
|
|
262
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
263
|
+
// Multi-account failover (OPT-IN)
|
|
264
|
+
//
|
|
265
|
+
// Inert unless <agentDir>/claude-pool.json exists with >=2 accounts and
|
|
266
|
+
// "enabled" !== false. Kill switch: PI_CLAUDE_PROVIDER_POOL_DISABLE=1.
|
|
267
|
+
//
|
|
268
|
+
// Mechanism: override the anthropic provider's OAuth credential supply so the
|
|
269
|
+
// per-request token is the *currently active* pooled account. On a 429/529
|
|
270
|
+
// (rate / usage cap) the active account is put on cooldown and the pointer
|
|
271
|
+
// flips to the other account, so the next turn uses it. Tokens are refreshed in
|
|
272
|
+
// an async before_agent_start hook because getApiKey() is synchronous.
|
|
273
|
+
//
|
|
274
|
+
// Subscription billing is preserved: Pi's transport detects the OAuth path from
|
|
275
|
+
// the token's SHAPE (isOAuthToken), so any genuine pooled OAuth token still gets
|
|
276
|
+
// the Claude Code identity headers automatically.
|
|
277
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
278
|
+
|
|
279
|
+
interface PoolAccount {
|
|
280
|
+
label: string;
|
|
281
|
+
refresh: string;
|
|
282
|
+
access: string;
|
|
283
|
+
expires: number;
|
|
284
|
+
}
|
|
285
|
+
interface PoolConfig {
|
|
286
|
+
enabled?: boolean;
|
|
287
|
+
accounts: PoolAccount[];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const POOL_PATH = join(
|
|
291
|
+
process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"),
|
|
292
|
+
"claude-pool.json",
|
|
293
|
+
);
|
|
294
|
+
const POOL_REFRESH_BUFFER_MS = 60_000;
|
|
295
|
+
const POOL_DEFAULT_COOLDOWN_MS = 5 * 60_000;
|
|
296
|
+
|
|
297
|
+
let poolAccounts: PoolAccount[] = [];
|
|
298
|
+
let poolActive = 0;
|
|
299
|
+
let poolCooldownUntil: number[] = [];
|
|
300
|
+
|
|
301
|
+
function poolLog(msg: string): void {
|
|
302
|
+
writeDebugLog({ stage: "pool", time: new Date().toISOString(), msg });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function loadPool(): boolean {
|
|
306
|
+
try {
|
|
307
|
+
if (!existsSync(POOL_PATH)) return false;
|
|
308
|
+
const cfg = JSON.parse(readFileSync(POOL_PATH, "utf-8")) as PoolConfig;
|
|
309
|
+
if (cfg.enabled === false) return false;
|
|
310
|
+
const accts = (cfg.accounts || []).filter(
|
|
311
|
+
(a) => a && a.refresh && a.access,
|
|
312
|
+
);
|
|
313
|
+
if (accts.length < 2) return false;
|
|
314
|
+
poolAccounts = accts;
|
|
315
|
+
poolCooldownUntil = accts.map(() => 0);
|
|
316
|
+
poolActive = 0;
|
|
317
|
+
return true;
|
|
318
|
+
} catch {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function persistPool(): void {
|
|
324
|
+
try {
|
|
325
|
+
writeFileSync(
|
|
326
|
+
POOL_PATH,
|
|
327
|
+
JSON.stringify({ enabled: true, accounts: poolAccounts }, null, 2),
|
|
328
|
+
{
|
|
329
|
+
mode: 0o600,
|
|
330
|
+
},
|
|
331
|
+
);
|
|
332
|
+
} catch {
|
|
333
|
+
/* persistence is best-effort */
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function poolSelectActive(): void {
|
|
338
|
+
const now = Date.now();
|
|
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.
|
|
343
|
+
let pick = -1;
|
|
344
|
+
for (let i = 0; i < poolAccounts.length; i++) {
|
|
345
|
+
if (poolCooldownUntil[i] <= now) {
|
|
346
|
+
pick = i;
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (pick === -1) {
|
|
351
|
+
// all cooling down → pick the one that frees up soonest
|
|
352
|
+
let min = Number.POSITIVE_INFINITY;
|
|
353
|
+
for (let i = 0; i < poolCooldownUntil.length; i++) {
|
|
354
|
+
if (poolCooldownUntil[i] < min) {
|
|
355
|
+
min = poolCooldownUntil[i];
|
|
356
|
+
pick = i;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (pick !== poolActive) {
|
|
361
|
+
poolActive = pick;
|
|
362
|
+
poolLog(`switched active → ${poolAccounts[poolActive].label}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function poolEnsureFresh(i: number): Promise<void> {
|
|
367
|
+
const acct = poolAccounts[i];
|
|
368
|
+
if (!acct || acct.expires > Date.now() + POOL_REFRESH_BUFFER_MS) return;
|
|
369
|
+
try {
|
|
370
|
+
const oauth = (await import("@earendil-works/pi-ai/oauth")) as {
|
|
371
|
+
refreshAnthropicToken: (
|
|
372
|
+
r: string,
|
|
373
|
+
) => Promise<{ refresh?: string; access: string; expires: number }>;
|
|
374
|
+
};
|
|
375
|
+
const next = await oauth.refreshAnthropicToken(acct.refresh);
|
|
376
|
+
acct.access = next.access;
|
|
377
|
+
acct.expires = next.expires;
|
|
378
|
+
if (next.refresh) acct.refresh = next.refresh;
|
|
379
|
+
persistPool();
|
|
380
|
+
poolLog(`refreshed ${acct.label}`);
|
|
381
|
+
} catch (e) {
|
|
382
|
+
poolLog(
|
|
383
|
+
`refresh FAILED ${acct.label}: ${(e as Error)?.message ?? String(e)}`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function poolMarkRateLimited(headers?: Record<string, string>): void {
|
|
389
|
+
const now = Date.now();
|
|
390
|
+
let until = now + POOL_DEFAULT_COOLDOWN_MS;
|
|
391
|
+
const ra =
|
|
392
|
+
headers?.["retry-after"] ?? headers?.["anthropic-ratelimit-unified-reset"];
|
|
393
|
+
if (ra) {
|
|
394
|
+
const n = Number(ra);
|
|
395
|
+
if (Number.isFinite(n)) until = n > 1e9 ? n * 1000 : now + n * 1000; // epoch-seconds vs delta-seconds
|
|
396
|
+
}
|
|
397
|
+
poolMarkRateLimitedUntil(until);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function poolMarkRateLimitedUntil(until: number): void {
|
|
401
|
+
poolCooldownUntil[poolActive] = until;
|
|
402
|
+
poolLog(
|
|
403
|
+
`rate-limited ${poolAccounts[poolActive].label} until ${new Date(until).toISOString()}`,
|
|
404
|
+
);
|
|
405
|
+
poolSelectActive();
|
|
406
|
+
}
|
|
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
|
+
|
|
436
|
+
// Read the current single-slot anthropic OAuth creds from auth.json.
|
|
437
|
+
function readAnthropicAuth():
|
|
438
|
+
| { refresh: string; access: string; expires: number }
|
|
439
|
+
| undefined {
|
|
440
|
+
try {
|
|
441
|
+
const authPath = join(
|
|
442
|
+
process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"),
|
|
443
|
+
"auth.json",
|
|
444
|
+
);
|
|
445
|
+
if (!existsSync(authPath)) return undefined;
|
|
446
|
+
const a = (
|
|
447
|
+
JSON.parse(readFileSync(authPath, "utf-8")) as { anthropic?: PoolAccount }
|
|
448
|
+
).anthropic;
|
|
449
|
+
if (!a || !a.access || !a.refresh) return undefined;
|
|
450
|
+
return { refresh: a.refresh, access: a.access, expires: a.expires ?? 0 };
|
|
451
|
+
} catch {
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Upsert an account (by label) into claude-pool.json. Returns total count.
|
|
457
|
+
function poolUpsert(
|
|
458
|
+
label: string,
|
|
459
|
+
creds: { refresh: string; access: string; expires: number },
|
|
460
|
+
): number {
|
|
461
|
+
let cfg: PoolConfig = { enabled: true, accounts: [] };
|
|
462
|
+
try {
|
|
463
|
+
if (existsSync(POOL_PATH))
|
|
464
|
+
cfg = JSON.parse(readFileSync(POOL_PATH, "utf-8")) as PoolConfig;
|
|
465
|
+
} catch {
|
|
466
|
+
/* start fresh on a corrupt file */
|
|
467
|
+
}
|
|
468
|
+
if (!Array.isArray(cfg.accounts)) cfg.accounts = [];
|
|
469
|
+
const entry: PoolAccount = { label, ...creds };
|
|
470
|
+
const idx = cfg.accounts.findIndex((x) => x.label === label);
|
|
471
|
+
if (idx >= 0) cfg.accounts[idx] = entry;
|
|
472
|
+
else cfg.accounts.push(entry);
|
|
473
|
+
cfg.enabled = cfg.enabled !== false;
|
|
474
|
+
writeFileSync(POOL_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
475
|
+
return cfg.accounts.length;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// /login enrollment + inspection. Always registered (touches files only, not
|
|
479
|
+
// the request path), so you can bootstrap the pool with the native /login.
|
|
480
|
+
function setupPoolCommands(pi: ExtensionAPI): void {
|
|
481
|
+
const register = pi.registerCommand as unknown as (
|
|
482
|
+
name: string,
|
|
483
|
+
def: {
|
|
484
|
+
description: string;
|
|
485
|
+
handler: (
|
|
486
|
+
args: string,
|
|
487
|
+
ctx: { ui: { notify: (m: string, l?: string) => void } },
|
|
488
|
+
) => Promise<void>;
|
|
489
|
+
},
|
|
490
|
+
) => void;
|
|
491
|
+
|
|
492
|
+
register("claude-pool-add", {
|
|
493
|
+
description:
|
|
494
|
+
"Snapshot the current '/login anthropic' account into the Claude failover pool. Usage: /claude-pool-add <label>",
|
|
495
|
+
handler: async (args, ctx) => {
|
|
496
|
+
const label = (args || "").trim() || `account-${Date.now()}`;
|
|
497
|
+
const creds = readAnthropicAuth();
|
|
498
|
+
if (!creds) {
|
|
499
|
+
ctx.ui.notify(
|
|
500
|
+
"No anthropic OAuth creds in auth.json — run /login anthropic first.",
|
|
501
|
+
"warning",
|
|
502
|
+
);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const n = poolUpsert(label, creds);
|
|
506
|
+
ctx.ui.notify(
|
|
507
|
+
`Added "${label}" to Claude pool (${n} account${n === 1 ? "" : "s"}). ${n >= 2 ? "Restart to activate failover." : "Now /login the other account and run this again."}`,
|
|
508
|
+
"info",
|
|
509
|
+
);
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
register("claude-pool-status", {
|
|
514
|
+
description: "Show the Claude failover pool accounts",
|
|
515
|
+
handler: async (_args, ctx) => {
|
|
516
|
+
if (!existsSync(POOL_PATH)) {
|
|
517
|
+
ctx.ui.notify(
|
|
518
|
+
"No claude-pool.json yet. Use /claude-pool-add <label> after /login.",
|
|
519
|
+
"info",
|
|
520
|
+
);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
const cfg = JSON.parse(readFileSync(POOL_PATH, "utf-8")) as PoolConfig;
|
|
525
|
+
const lines = (cfg.accounts || []).map(
|
|
526
|
+
(a, i) =>
|
|
527
|
+
` ${i}. ${a.label} (exp ${a.expires ? new Date(a.expires).toISOString() : "?"})`,
|
|
528
|
+
);
|
|
529
|
+
ctx.ui.notify(
|
|
530
|
+
`Claude pool (enabled=${cfg.enabled !== false}):\n${lines.join("\n") || " (none)"}`,
|
|
531
|
+
"info",
|
|
532
|
+
);
|
|
533
|
+
} catch (e) {
|
|
534
|
+
ctx.ui.notify(`pool read error: ${(e as Error).message}`, "warning");
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function setupPool(pi: ExtensionAPI): void {
|
|
541
|
+
if (process.env.PI_CLAUDE_PROVIDER_POOL_DISABLE === "1") return;
|
|
542
|
+
if (!loadPool()) return; // inert unless a valid claude-pool.json exists
|
|
543
|
+
poolLog(`pool active: ${poolAccounts.map((a) => a.label).join(", ")}`);
|
|
544
|
+
|
|
545
|
+
const prep = async () => {
|
|
546
|
+
poolSelectActive();
|
|
547
|
+
await poolEnsureFresh(poolActive);
|
|
548
|
+
};
|
|
549
|
+
pi.on("session_start", async () => {
|
|
550
|
+
await prep();
|
|
551
|
+
});
|
|
552
|
+
pi.on("before_agent_start", async () => {
|
|
553
|
+
await prep();
|
|
554
|
+
});
|
|
555
|
+
|
|
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).
|
|
601
|
+
pi.on("after_provider_response", (event: unknown) => {
|
|
602
|
+
try {
|
|
603
|
+
const e = event as {
|
|
604
|
+
status?: number;
|
|
605
|
+
statusCode?: number;
|
|
606
|
+
headers?: Record<string, string>;
|
|
607
|
+
};
|
|
608
|
+
const status = e.status ?? e.statusCode;
|
|
609
|
+
if (status === 429 || status === 529) poolMarkRateLimited(e.headers);
|
|
610
|
+
} catch {
|
|
611
|
+
/* observation only — never break the response path */
|
|
612
|
+
}
|
|
613
|
+
return undefined;
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
// Supply the active pooled account's token to Pi's Anthropic transport.
|
|
617
|
+
const register = pi.registerProvider as unknown as (
|
|
618
|
+
id: string,
|
|
619
|
+
cfg: unknown,
|
|
620
|
+
) => void;
|
|
621
|
+
register("anthropic", {
|
|
622
|
+
oauth: {
|
|
623
|
+
name: `Claude (pooled: ${poolAccounts.map((a) => a.label).join("+")})`,
|
|
624
|
+
async login(callbacks: unknown) {
|
|
625
|
+
const m = (await import("@earendil-works/pi-ai/oauth")) as {
|
|
626
|
+
anthropicOAuthProvider: { login: (c: unknown) => Promise<unknown> };
|
|
627
|
+
};
|
|
628
|
+
return m.anthropicOAuthProvider.login(callbacks);
|
|
629
|
+
},
|
|
630
|
+
async refreshToken(_creds: unknown) {
|
|
631
|
+
await poolEnsureFresh(poolActive);
|
|
632
|
+
const a = poolAccounts[poolActive];
|
|
633
|
+
return { refresh: a.refresh, access: a.access, expires: a.expires };
|
|
634
|
+
},
|
|
635
|
+
getApiKey(creds: { access?: string } | undefined) {
|
|
636
|
+
const a = poolAccounts[poolActive];
|
|
637
|
+
return (a && a.access) || creds?.access || "";
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
255
643
|
export default function piProviderClaude(pi: ExtensionAPI): void {
|
|
256
644
|
// mcp__pi__* → flat, before the agent loop resolves the tool.
|
|
257
645
|
pi.on("message_end", async (event, _ctx) => {
|
|
@@ -280,4 +668,8 @@ export default function piProviderClaude(pi: ExtensionAPI): void {
|
|
|
280
668
|
writeDebugLog({ stage: "after", payload: transformed });
|
|
281
669
|
return transformed;
|
|
282
670
|
});
|
|
671
|
+
|
|
672
|
+
// Multi-account failover.
|
|
673
|
+
setupPoolCommands(pi); // /claude-pool-add, /claude-pool-status (always available)
|
|
674
|
+
setupPool(pi); // failover provider override (active once >=2 accounts exist)
|
|
283
675
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zgltyq/pi-provider-claude",
|
|
3
|
-
"version": "1.
|
|
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. Self-contained fork of the tool half of @benvargas/pi-claude-code-use.",
|
|
3
|
+
"version": "1.2.0",
|
|
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",
|
|
7
7
|
"repository": {
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"index.ts",
|
|
34
34
|
"package.json",
|
|
35
35
|
"README.md",
|
|
36
|
-
"LICENSE"
|
|
36
|
+
"LICENSE",
|
|
37
|
+
"harvest-claude-pool.sh"
|
|
37
38
|
],
|
|
38
39
|
"pi": {
|
|
39
40
|
"extensions": [
|