@zgltyq/pi-provider-claude 1.2.0 → 1.3.1

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 (4) hide show
  1. package/README.md +17 -0
  2. package/index.ts +90 -17
  3. package/oauth.ts +254 -0
  4. package/package.json +2 -1
package/README.md CHANGED
@@ -91,6 +91,23 @@ 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
+ **OAuth compatibility and background token refresh (v1.3.1):** The pool now
95
+ uses its own Anthropic OAuth login/refresh adapter, keeping it compatible with
96
+ pi 0.81.x where the old runtime OAuth export is no longer available. OAuth access
97
+ tokens are short-lived. An account that sat idle (e.g. your fallback) would have
98
+ a dead access token by the time failover switched to it — producing a `401
99
+ authentication_error`. v1.3.1 retains the v1.3.0 keep-warm behavior:
100
+
101
+ - A `setInterval` keep-warm loop that refreshes **every** pooled account before it
102
+ expires, even when idle and not the active account (the timer is `unref()`'d so
103
+ it never blocks CLI commands like `pi update` from exiting).
104
+ - A wider 5-minute pre-expiry refresh buffer, and a full-pool refresh on
105
+ `session_start` / `before_agent_start` (not just the active account).
106
+ - Reactive 401 handling: if the server rejects the active token anyway, it is
107
+ force-refreshed in place (no account switch) so the resend succeeds. If the
108
+ refresh token itself was revoked, you're told to re-run `/login` +
109
+ `/claude-pool-add <label>`.
110
+
94
111
  It is **inert** unless `<agentDir>/claude-pool.json` exists with ≥2 accounts and
95
112
  `"enabled" !== false`.
96
113
 
package/index.ts CHANGED
@@ -45,6 +45,10 @@ import {
45
45
  import { homedir } from "node:os";
46
46
  import { join } from "node:path";
47
47
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
48
+ import {
49
+ loginAnthropicOAuth,
50
+ refreshAnthropicOAuth,
51
+ } from "./oauth.ts";
48
52
 
49
53
  // Prefix used to disguise flat tools as MCP tools. Kept short so
50
54
  // `mcp__pi__<name>` stays well under Anthropic's 64-char tool-name limit.
@@ -291,12 +295,20 @@ const POOL_PATH = join(
291
295
  process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"),
292
296
  "claude-pool.json",
293
297
  );
294
- const POOL_REFRESH_BUFFER_MS = 60_000;
298
+ // Refresh an access token this long BEFORE it actually expires, so we never
299
+ // hand a request a token that's about to die. Generous because OAuth access
300
+ // tokens are short-lived and the failover target may have sat idle for days.
301
+ const POOL_REFRESH_BUFFER_MS = 5 * 60_000;
295
302
  const POOL_DEFAULT_COOLDOWN_MS = 5 * 60_000;
303
+ // Background keep-warm cadence: re-check every account on this interval even
304
+ // when idle / not the active one, so an account is never stale when we switch.
305
+ const POOL_BG_REFRESH_INTERVAL_MS = 4 * 60_000;
296
306
 
297
307
  let poolAccounts: PoolAccount[] = [];
298
308
  let poolActive = 0;
299
309
  let poolCooldownUntil: number[] = [];
310
+ let poolBgTimer: ReturnType<typeof setInterval> | undefined;
311
+ let poolRefreshInFlight = false;
300
312
 
301
313
  function poolLog(msg: string): void {
302
314
  writeDebugLog({ stage: "pool", time: new Date().toISOString(), msg });
@@ -363,25 +375,51 @@ function poolSelectActive(): void {
363
375
  }
364
376
  }
365
377
 
366
- async function poolEnsureFresh(i: number): Promise<void> {
378
+ // Low-level refresh of account `i`. Returns true on success. When `force` is
379
+ // false it skips accounts whose token is still comfortably valid.
380
+ async function poolRefreshAccount(i: number, force: boolean): Promise<boolean> {
367
381
  const acct = poolAccounts[i];
368
- if (!acct || acct.expires > Date.now() + POOL_REFRESH_BUFFER_MS) return;
382
+ if (!acct) return false;
383
+ if (!force && acct.expires > Date.now() + POOL_REFRESH_BUFFER_MS) return true;
369
384
  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);
385
+ const next = await refreshAnthropicOAuth({ refresh: acct.refresh });
376
386
  acct.access = next.access;
377
387
  acct.expires = next.expires;
378
388
  if (next.refresh) acct.refresh = next.refresh;
379
389
  persistPool();
380
- poolLog(`refreshed ${acct.label}`);
390
+ poolLog(`refreshed ${acct.label} (exp ${new Date(acct.expires).toISOString()})`);
391
+ return true;
381
392
  } catch (e) {
382
393
  poolLog(
383
394
  `refresh FAILED ${acct.label}: ${(e as Error)?.message ?? String(e)}`,
384
395
  );
396
+ return false;
397
+ }
398
+ }
399
+
400
+ // Refresh account `i` only if it is near (or past) expiry.
401
+ async function poolEnsureFresh(i: number): Promise<void> {
402
+ await poolRefreshAccount(i, false);
403
+ }
404
+
405
+ // Force a refresh of account `i` regardless of expiry (used on a 401, when the
406
+ // server rejected the token even though we thought it was still valid).
407
+ async function poolForceRefresh(i: number): Promise<boolean> {
408
+ return poolRefreshAccount(i, true);
409
+ }
410
+
411
+ // Background keep-warm: refresh EVERY pooled account whose token is near expiry,
412
+ // not just the active one. This is what guarantees the failover target is never
413
+ // stale when we switch to it after it sat unused for a long time.
414
+ async function poolRefreshAll(): Promise<void> {
415
+ if (poolRefreshInFlight) return; // never overlap refresh sweeps
416
+ poolRefreshInFlight = true;
417
+ try {
418
+ for (let i = 0; i < poolAccounts.length; i++) {
419
+ await poolEnsureFresh(i);
420
+ }
421
+ } finally {
422
+ poolRefreshInFlight = false;
385
423
  }
386
424
  }
387
425
 
@@ -415,6 +453,13 @@ function poolMarkRateLimitedUntil(until: number): void {
415
453
  const POOL_LIMIT_RE =
416
454
  /\b429\b|\b529\b|rate[ _-]?limit|usage[ _-]?limit|overloaded_error|quota/i;
417
455
 
456
+ // Expired / rejected credential detection (401). When the active account's
457
+ // token is rejected we force-refresh it in place so the resend succeeds — this
458
+ // is the symptom of switching to an account that sat idle until its access
459
+ // token died. Anchored to avoid colliding with the 429 message.
460
+ const POOL_AUTH_RE =
461
+ /authentication_error|invalid authentication credentials|\b401\b/i;
462
+
418
463
  // Claude subscription cap messages often carry a reset epoch after a pipe,
419
464
  // e.g. "Claude AI usage limit reached|1750000000".
420
465
  function poolParseResetEpoch(message: string): number | undefined {
@@ -544,7 +589,9 @@ function setupPool(pi: ExtensionAPI): void {
544
589
 
545
590
  const prep = async () => {
546
591
  poolSelectActive();
547
- await poolEnsureFresh(poolActive);
592
+ // Keep ALL accounts warm, not just the active one, so the failover target
593
+ // is ready the instant we switch to it.
594
+ await poolRefreshAll();
548
595
  };
549
596
  pi.on("session_start", async () => {
550
597
  await prep();
@@ -553,6 +600,20 @@ function setupPool(pi: ExtensionAPI): void {
553
600
  await prep();
554
601
  });
555
602
 
603
+ // Background keep-warm loop: refresh every account before it expires even
604
+ // while idle / not active. This is the core fix — the failover target is kept
605
+ // fresh continuously so it's never expired when we switch to it.
606
+ //
607
+ // unref() is CRITICAL: an active timer handle keeps the Node event loop alive
608
+ // and would prevent short-lived CLI commands (e.g. `pi update`) from exiting.
609
+ // We also do NOT fire an immediate refresh here — interactive sessions warm
610
+ // via session_start; firing at module load would add network I/O to the CLI
611
+ // path. The first tick lands one interval later, only in long-lived sessions.
612
+ poolBgTimer = setInterval(() => {
613
+ void poolRefreshAll();
614
+ }, POOL_BG_REFRESH_INTERVAL_MS);
615
+ if (typeof poolBgTimer.unref === "function") poolBgTimer.unref();
616
+
556
617
  // PRIMARY rate/usage-cap detector: provider errors surface on the assistant
557
618
  // message (stopReason "error"), not via after_provider_response (the SDK
558
619
  // throws on 429/529 before pi-ai's onResponse callback runs).
@@ -577,6 +638,23 @@ function setupPool(pi: ExtensionAPI): void {
577
638
  )
578
639
  return undefined;
579
640
  const prev = poolAccounts[poolActive]?.label;
641
+
642
+ // 401 / expired-credential path: the server rejected the ACTIVE token.
643
+ // Force-refresh it in place (don't switch accounts) so the resend works.
644
+ if (
645
+ POOL_AUTH_RE.test(msg.errorMessage) &&
646
+ !POOL_LIMIT_RE.test(msg.errorMessage)
647
+ ) {
648
+ const ok = await poolForceRefresh(poolActive);
649
+ ctx.ui.notify(
650
+ ok
651
+ ? `Claude account "${prev}" had an expired token — refreshed it. Resend your message to continue.`
652
+ : `Claude account "${prev}" token is invalid and refresh FAILED — its refresh token was likely revoked. Run /login anthropic then /claude-pool-add ${prev}.`,
653
+ ok ? "info" : "warning",
654
+ );
655
+ return undefined;
656
+ }
657
+
580
658
  if (!poolHandleErrorMessage(msg.errorMessage)) return undefined;
581
659
  const next = poolAccounts[poolActive]?.label;
582
660
  if (next !== prev) {
@@ -621,12 +699,7 @@ function setupPool(pi: ExtensionAPI): void {
621
699
  register("anthropic", {
622
700
  oauth: {
623
701
  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
- },
702
+ login: loginAnthropicOAuth,
630
703
  async refreshToken(_creds: unknown) {
631
704
  await poolEnsureFresh(poolActive);
632
705
  const a = poolAccounts[poolActive];
package/oauth.ts ADDED
@@ -0,0 +1,254 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+
4
+ export interface AnthropicOAuthCredential {
5
+ type: "oauth";
6
+ refresh: string;
7
+ access: string;
8
+ expires: number;
9
+ }
10
+
11
+ export interface AnthropicOAuthCallbacks {
12
+ notify: (event: Record<string, unknown>) => void;
13
+ prompt: (prompt: Record<string, unknown>) => Promise<string>;
14
+ }
15
+
16
+ const CLIENT_ID = Buffer.from("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl", "base64").toString("utf8");
17
+ const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
18
+ const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
19
+ const CALLBACK_HOST = process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1";
20
+ const CALLBACK_PORT = 53692;
21
+ const CALLBACK_PATH = "/callback";
22
+ const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
23
+ const SCOPES = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
24
+
25
+ type AuthorizationResult = { code: string; state: string };
26
+
27
+ function parseAuthorizationInput(input: string): { code?: string; state?: string } {
28
+ const value = input.trim();
29
+ if (!value) return {};
30
+ try {
31
+ const url = new URL(value);
32
+ return {
33
+ code: url.searchParams.get("code") ?? undefined,
34
+ state: url.searchParams.get("state") ?? undefined,
35
+ };
36
+ } catch {
37
+ // Not a URL; continue with the other supported formats.
38
+ }
39
+ if (value.includes("#")) {
40
+ const [code, state] = value.split("#", 2);
41
+ return { code, state };
42
+ }
43
+ if (value.includes("code=")) {
44
+ const params = new URLSearchParams(value);
45
+ return {
46
+ code: params.get("code") ?? undefined,
47
+ state: params.get("state") ?? undefined,
48
+ };
49
+ }
50
+ return { code: value };
51
+ }
52
+
53
+ function formatErrorDetails(error: unknown): string {
54
+ if (!(error instanceof Error)) return String(error);
55
+ return `${error.name}: ${error.message}${error.stack ? `; stack=${error.stack}` : ""}`;
56
+ }
57
+
58
+ async function startCallbackServer(expectedState: string): Promise<{
59
+ server: ReturnType<typeof createServer>;
60
+ cancelWait: () => void;
61
+ waitForCode: () => Promise<AuthorizationResult | null>;
62
+ }> {
63
+ return new Promise((resolve, reject) => {
64
+ let settleWait: (value: AuthorizationResult | null) => void = () => undefined;
65
+ const waitForCode = new Promise<AuthorizationResult | null>((resolveWait) => {
66
+ let settled = false;
67
+ settleWait = (value) => {
68
+ if (settled) return;
69
+ settled = true;
70
+ resolveWait(value);
71
+ };
72
+ });
73
+ const server = createServer((req, res) => {
74
+ try {
75
+ const url = new URL(req.url || "", "http://localhost");
76
+ if (url.pathname !== CALLBACK_PATH) {
77
+ res.writeHead(404);
78
+ res.end("Callback route not found.");
79
+ return;
80
+ }
81
+ const code = url.searchParams.get("code");
82
+ const state = url.searchParams.get("state");
83
+ const error = url.searchParams.get("error");
84
+ if (error) {
85
+ res.writeHead(400);
86
+ res.end(`Anthropic authentication did not complete: ${error}`);
87
+ return;
88
+ }
89
+ if (!code || !state) {
90
+ res.writeHead(400);
91
+ res.end("Missing code or state parameter.");
92
+ return;
93
+ }
94
+ if (state !== expectedState) {
95
+ res.writeHead(400);
96
+ res.end("State mismatch.");
97
+ return;
98
+ }
99
+ res.writeHead(200);
100
+ res.end("Anthropic authentication completed. You can close this window.");
101
+ settleWait({ code, state });
102
+ } catch {
103
+ res.writeHead(500);
104
+ res.end("Internal error");
105
+ }
106
+ });
107
+ server.once("error", reject);
108
+ server.listen(CALLBACK_PORT, CALLBACK_HOST, () =>
109
+ resolve({
110
+ server,
111
+ cancelWait: () => settleWait(null),
112
+ waitForCode: () => waitForCode,
113
+ }),
114
+ );
115
+ });
116
+ }
117
+
118
+ async function postJson(url: string, body: Record<string, unknown>): Promise<string> {
119
+ const response = await fetch(url, {
120
+ method: "POST",
121
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
122
+ body: JSON.stringify(body),
123
+ signal: AbortSignal.timeout(30_000),
124
+ });
125
+ const responseBody = await response.text();
126
+ if (!response.ok) {
127
+ throw new Error(`HTTP request failed. status=${response.status}; url=${url}; body=${responseBody}`);
128
+ }
129
+ return responseBody;
130
+ }
131
+
132
+ async function exchangeAuthorizationCode(
133
+ code: string,
134
+ state: string,
135
+ verifier: string,
136
+ ): Promise<AnthropicOAuthCredential> {
137
+ let responseBody: string;
138
+ try {
139
+ responseBody = await postJson(TOKEN_URL, {
140
+ grant_type: "authorization_code",
141
+ client_id: CLIENT_ID,
142
+ code,
143
+ state,
144
+ redirect_uri: REDIRECT_URI,
145
+ code_verifier: verifier,
146
+ });
147
+ } catch (error) {
148
+ throw new Error(`Token exchange request failed. details=${formatErrorDetails(error)}`);
149
+ }
150
+ let tokenData: { refresh_token?: string; access_token?: string; expires_in?: number };
151
+ try {
152
+ tokenData = JSON.parse(responseBody) as typeof tokenData;
153
+ } catch (error) {
154
+ throw new Error(`Token exchange returned invalid JSON. body=${responseBody}; details=${formatErrorDetails(error)}`);
155
+ }
156
+ if (!tokenData.refresh_token || !tokenData.access_token || !tokenData.expires_in) {
157
+ throw new Error("Token exchange response did not contain complete OAuth credentials.");
158
+ }
159
+ return {
160
+ type: "oauth",
161
+ refresh: tokenData.refresh_token,
162
+ access: tokenData.access_token,
163
+ expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000,
164
+ };
165
+ }
166
+
167
+ export async function loginAnthropicOAuth(
168
+ interaction: AnthropicOAuthCallbacks,
169
+ ): Promise<AnthropicOAuthCredential> {
170
+ const verifier = randomBytes(32).toString("base64url");
171
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
172
+ const server = await startCallbackServer(verifier);
173
+ const manualAbort = new AbortController();
174
+ let manualInput: string | undefined;
175
+ let manualError: Error | undefined;
176
+ try {
177
+ const authParams = new URLSearchParams({
178
+ code: "true",
179
+ client_id: CLIENT_ID,
180
+ response_type: "code",
181
+ redirect_uri: REDIRECT_URI,
182
+ scope: SCOPES,
183
+ code_challenge: challenge,
184
+ code_challenge_method: "S256",
185
+ state: verifier,
186
+ });
187
+ interaction.notify({
188
+ type: "auth_url",
189
+ url: `${AUTHORIZE_URL}?${authParams.toString()}`,
190
+ instructions: "Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.",
191
+ });
192
+ const manualPromise = interaction
193
+ .prompt({
194
+ type: "manual_code",
195
+ message: "Complete login in your browser, or paste the authorization code / redirect URL here:",
196
+ placeholder: REDIRECT_URI,
197
+ signal: manualAbort.signal,
198
+ })
199
+ .then((input) => {
200
+ manualInput = input;
201
+ server.cancelWait();
202
+ })
203
+ .catch((error: unknown) => {
204
+ manualError = error instanceof Error ? error : new Error(String(error));
205
+ server.cancelWait();
206
+ });
207
+ const result = await server.waitForCode();
208
+ if (manualError) throw manualError;
209
+ const parsed = result ?? (manualInput ? parseAuthorizationInput(manualInput) : {});
210
+ if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch");
211
+ if (!parsed.code) {
212
+ await manualPromise;
213
+ if (manualError) throw manualError;
214
+ const fallback = parseAuthorizationInput(manualInput ?? "");
215
+ if (fallback.state && fallback.state !== verifier) throw new Error("OAuth state mismatch");
216
+ parsed.code = fallback.code;
217
+ }
218
+ if (!parsed.code) throw new Error("Missing authorization code");
219
+ interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." });
220
+ return exchangeAuthorizationCode(parsed.code, parsed.state ?? verifier, verifier);
221
+ } finally {
222
+ manualAbort.abort();
223
+ server.server.close();
224
+ }
225
+ }
226
+
227
+ export async function refreshAnthropicOAuth(
228
+ credential: Pick<AnthropicOAuthCredential, "refresh">,
229
+ ): Promise<AnthropicOAuthCredential> {
230
+ let responseBody: string;
231
+ try {
232
+ responseBody = await postJson(TOKEN_URL, {
233
+ grant_type: "refresh_token",
234
+ client_id: CLIENT_ID,
235
+ refresh_token: credential.refresh,
236
+ });
237
+ } catch (error) {
238
+ throw new Error(`Anthropic token refresh request failed. details=${formatErrorDetails(error)}`);
239
+ }
240
+ const data = JSON.parse(responseBody) as {
241
+ refresh_token?: string;
242
+ access_token?: string;
243
+ expires_in?: number;
244
+ };
245
+ if (!data.access_token || !data.expires_in) {
246
+ throw new Error("Anthropic token refresh response did not contain complete OAuth credentials.");
247
+ }
248
+ return {
249
+ type: "oauth",
250
+ refresh: data.refresh_token ?? credential.refresh,
251
+ access: data.access_token,
252
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
253
+ };
254
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgltyq/pi-provider-claude",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
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",
@@ -31,6 +31,7 @@
31
31
  "type": "module",
32
32
  "files": [
33
33
  "index.ts",
34
+ "oauth.ts",
34
35
  "package.json",
35
36
  "README.md",
36
37
  "LICENSE",