@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.
- package/README.md +17 -0
- package/index.ts +90 -17
- package/oauth.ts +254 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
382
|
+
if (!acct) return false;
|
|
383
|
+
if (!force && acct.expires > Date.now() + POOL_REFRESH_BUFFER_MS) return true;
|
|
369
384
|
try {
|
|
370
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|