@zgltyq/pi-provider-claude 1.3.0 → 1.3.2
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 +6 -3
- package/index.ts +6 -12
- package/oauth.ts +248 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -91,9 +91,12 @@ 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
|
-
**
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
**OAuth compatibility and background token refresh (v1.3.2):** The pool now
|
|
95
|
+
uses its own Anthropic OAuth login/refresh adapter and the login callback bridge
|
|
96
|
+
matches pi 0.81.x, where the old runtime OAuth export is no longer available.
|
|
97
|
+
OAuth access tokens are short-lived. An account that sat idle (e.g. your fallback)
|
|
98
|
+
would have a dead access token by the time failover switched to it — producing a
|
|
99
|
+
`401 authentication_error`. v1.3.2 retains the v1.3.0 keep-warm behavior:
|
|
97
100
|
|
|
98
101
|
- A `setInterval` keep-warm loop that refreshes **every** pooled account before it
|
|
99
102
|
expires, even when idle and not the active account (the timer is `unref()`'d so
|
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.
|
|
@@ -378,12 +382,7 @@ async function poolRefreshAccount(i: number, force: boolean): Promise<boolean> {
|
|
|
378
382
|
if (!acct) return false;
|
|
379
383
|
if (!force && acct.expires > Date.now() + POOL_REFRESH_BUFFER_MS) return true;
|
|
380
384
|
try {
|
|
381
|
-
const
|
|
382
|
-
refreshAnthropicToken: (
|
|
383
|
-
r: string,
|
|
384
|
-
) => Promise<{ refresh?: string; access: string; expires: number }>;
|
|
385
|
-
};
|
|
386
|
-
const next = await oauth.refreshAnthropicToken(acct.refresh);
|
|
385
|
+
const next = await refreshAnthropicOAuth({ refresh: acct.refresh });
|
|
387
386
|
acct.access = next.access;
|
|
388
387
|
acct.expires = next.expires;
|
|
389
388
|
if (next.refresh) acct.refresh = next.refresh;
|
|
@@ -700,12 +699,7 @@ function setupPool(pi: ExtensionAPI): void {
|
|
|
700
699
|
register("anthropic", {
|
|
701
700
|
oauth: {
|
|
702
701
|
name: `Claude (pooled: ${poolAccounts.map((a) => a.label).join("+")})`,
|
|
703
|
-
|
|
704
|
-
const m = (await import("@earendil-works/pi-ai/oauth")) as {
|
|
705
|
-
anthropicOAuthProvider: { login: (c: unknown) => Promise<unknown> };
|
|
706
|
-
};
|
|
707
|
-
return m.anthropicOAuthProvider.login(callbacks);
|
|
708
|
-
},
|
|
702
|
+
login: loginAnthropicOAuth,
|
|
709
703
|
async refreshToken(_creds: unknown) {
|
|
710
704
|
await poolEnsureFresh(poolActive);
|
|
711
705
|
const a = poolAccounts[poolActive];
|
package/oauth.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
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 AnthropicOAuthLoginCallbacks {
|
|
12
|
+
onAuth: (info: { url: string; instructions?: string }) => void;
|
|
13
|
+
onManualCodeInput: () => Promise<string>;
|
|
14
|
+
onProgress?: (message: string) => void;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const CLIENT_ID = Buffer.from("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl", "base64").toString("utf8");
|
|
19
|
+
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
|
20
|
+
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
|
21
|
+
const CALLBACK_HOST = process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1";
|
|
22
|
+
const CALLBACK_PORT = 53692;
|
|
23
|
+
const CALLBACK_PATH = "/callback";
|
|
24
|
+
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
|
25
|
+
const SCOPES = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
|
|
26
|
+
|
|
27
|
+
type AuthorizationResult = { code: string; state: string };
|
|
28
|
+
|
|
29
|
+
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
|
|
30
|
+
const value = input.trim();
|
|
31
|
+
if (!value) return {};
|
|
32
|
+
try {
|
|
33
|
+
const url = new URL(value);
|
|
34
|
+
return {
|
|
35
|
+
code: url.searchParams.get("code") ?? undefined,
|
|
36
|
+
state: url.searchParams.get("state") ?? undefined,
|
|
37
|
+
};
|
|
38
|
+
} catch {
|
|
39
|
+
// Not a URL; continue with the other supported formats.
|
|
40
|
+
}
|
|
41
|
+
if (value.includes("#")) {
|
|
42
|
+
const [code, state] = value.split("#", 2);
|
|
43
|
+
return { code, state };
|
|
44
|
+
}
|
|
45
|
+
if (value.includes("code=")) {
|
|
46
|
+
const params = new URLSearchParams(value);
|
|
47
|
+
return {
|
|
48
|
+
code: params.get("code") ?? undefined,
|
|
49
|
+
state: params.get("state") ?? undefined,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return { code: value };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatErrorDetails(error: unknown): string {
|
|
56
|
+
if (!(error instanceof Error)) return String(error);
|
|
57
|
+
return `${error.name}: ${error.message}${error.stack ? `; stack=${error.stack}` : ""}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function startCallbackServer(expectedState: string): Promise<{
|
|
61
|
+
server: ReturnType<typeof createServer>;
|
|
62
|
+
cancelWait: () => void;
|
|
63
|
+
waitForCode: () => Promise<AuthorizationResult | null>;
|
|
64
|
+
}> {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
let settleWait: (value: AuthorizationResult | null) => void = () => undefined;
|
|
67
|
+
const waitForCode = new Promise<AuthorizationResult | null>((resolveWait) => {
|
|
68
|
+
let settled = false;
|
|
69
|
+
settleWait = (value) => {
|
|
70
|
+
if (settled) return;
|
|
71
|
+
settled = true;
|
|
72
|
+
resolveWait(value);
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
const server = createServer((req, res) => {
|
|
76
|
+
try {
|
|
77
|
+
const url = new URL(req.url || "", "http://localhost");
|
|
78
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
79
|
+
res.writeHead(404);
|
|
80
|
+
res.end("Callback route not found.");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const code = url.searchParams.get("code");
|
|
84
|
+
const state = url.searchParams.get("state");
|
|
85
|
+
const error = url.searchParams.get("error");
|
|
86
|
+
if (error) {
|
|
87
|
+
res.writeHead(400);
|
|
88
|
+
res.end(`Anthropic authentication did not complete: ${error}`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!code || !state) {
|
|
92
|
+
res.writeHead(400);
|
|
93
|
+
res.end("Missing code or state parameter.");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (state !== expectedState) {
|
|
97
|
+
res.writeHead(400);
|
|
98
|
+
res.end("State mismatch.");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
res.writeHead(200);
|
|
102
|
+
res.end("Anthropic authentication completed. You can close this window.");
|
|
103
|
+
settleWait({ code, state });
|
|
104
|
+
} catch {
|
|
105
|
+
res.writeHead(500);
|
|
106
|
+
res.end("Internal error");
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
server.once("error", reject);
|
|
110
|
+
server.listen(CALLBACK_PORT, CALLBACK_HOST, () =>
|
|
111
|
+
resolve({
|
|
112
|
+
server,
|
|
113
|
+
cancelWait: () => settleWait(null),
|
|
114
|
+
waitForCode: () => waitForCode,
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function postJson(url: string, body: Record<string, unknown>): Promise<string> {
|
|
121
|
+
const response = await fetch(url, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
124
|
+
body: JSON.stringify(body),
|
|
125
|
+
signal: AbortSignal.timeout(30_000),
|
|
126
|
+
});
|
|
127
|
+
const responseBody = await response.text();
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
throw new Error(`HTTP request failed. status=${response.status}; url=${url}; body=${responseBody}`);
|
|
130
|
+
}
|
|
131
|
+
return responseBody;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function exchangeAuthorizationCode(
|
|
135
|
+
code: string,
|
|
136
|
+
state: string,
|
|
137
|
+
verifier: string,
|
|
138
|
+
): Promise<AnthropicOAuthCredential> {
|
|
139
|
+
let responseBody: string;
|
|
140
|
+
try {
|
|
141
|
+
responseBody = await postJson(TOKEN_URL, {
|
|
142
|
+
grant_type: "authorization_code",
|
|
143
|
+
client_id: CLIENT_ID,
|
|
144
|
+
code,
|
|
145
|
+
state,
|
|
146
|
+
redirect_uri: REDIRECT_URI,
|
|
147
|
+
code_verifier: verifier,
|
|
148
|
+
});
|
|
149
|
+
} catch (error) {
|
|
150
|
+
throw new Error(`Token exchange request failed. details=${formatErrorDetails(error)}`);
|
|
151
|
+
}
|
|
152
|
+
let tokenData: { refresh_token?: string; access_token?: string; expires_in?: number };
|
|
153
|
+
try {
|
|
154
|
+
tokenData = JSON.parse(responseBody) as typeof tokenData;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
throw new Error(`Token exchange returned invalid JSON. body=${responseBody}; details=${formatErrorDetails(error)}`);
|
|
157
|
+
}
|
|
158
|
+
if (!tokenData.refresh_token || !tokenData.access_token || !tokenData.expires_in) {
|
|
159
|
+
throw new Error("Token exchange response did not contain complete OAuth credentials.");
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
type: "oauth",
|
|
163
|
+
refresh: tokenData.refresh_token,
|
|
164
|
+
access: tokenData.access_token,
|
|
165
|
+
expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function loginAnthropicOAuth(
|
|
170
|
+
callbacks: AnthropicOAuthLoginCallbacks,
|
|
171
|
+
): Promise<AnthropicOAuthCredential> {
|
|
172
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
173
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
174
|
+
const server = await startCallbackServer(verifier);
|
|
175
|
+
let manualInput: string | undefined;
|
|
176
|
+
let manualError: Error | undefined;
|
|
177
|
+
try {
|
|
178
|
+
const authParams = new URLSearchParams({
|
|
179
|
+
code: "true",
|
|
180
|
+
client_id: CLIENT_ID,
|
|
181
|
+
response_type: "code",
|
|
182
|
+
redirect_uri: REDIRECT_URI,
|
|
183
|
+
scope: SCOPES,
|
|
184
|
+
code_challenge: challenge,
|
|
185
|
+
code_challenge_method: "S256",
|
|
186
|
+
state: verifier,
|
|
187
|
+
});
|
|
188
|
+
callbacks.onAuth({
|
|
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 = callbacks
|
|
193
|
+
.onManualCodeInput()
|
|
194
|
+
.then((input) => {
|
|
195
|
+
manualInput = input;
|
|
196
|
+
server.cancelWait();
|
|
197
|
+
})
|
|
198
|
+
.catch((error: unknown) => {
|
|
199
|
+
manualError = error instanceof Error ? error : new Error(String(error));
|
|
200
|
+
server.cancelWait();
|
|
201
|
+
});
|
|
202
|
+
const result = await server.waitForCode();
|
|
203
|
+
if (manualError) throw manualError;
|
|
204
|
+
const parsed = result ?? (manualInput ? parseAuthorizationInput(manualInput) : {});
|
|
205
|
+
if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch");
|
|
206
|
+
if (!parsed.code) {
|
|
207
|
+
await manualPromise;
|
|
208
|
+
if (manualError) throw manualError;
|
|
209
|
+
const fallback = parseAuthorizationInput(manualInput ?? "");
|
|
210
|
+
if (fallback.state && fallback.state !== verifier) throw new Error("OAuth state mismatch");
|
|
211
|
+
parsed.code = fallback.code;
|
|
212
|
+
}
|
|
213
|
+
if (!parsed.code) throw new Error("Missing authorization code");
|
|
214
|
+
callbacks.onProgress?.("Exchanging authorization code for tokens...");
|
|
215
|
+
return exchangeAuthorizationCode(parsed.code, parsed.state ?? verifier, verifier);
|
|
216
|
+
} finally {
|
|
217
|
+
server.server.close();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function refreshAnthropicOAuth(
|
|
222
|
+
credential: Pick<AnthropicOAuthCredential, "refresh">,
|
|
223
|
+
): Promise<AnthropicOAuthCredential> {
|
|
224
|
+
let responseBody: string;
|
|
225
|
+
try {
|
|
226
|
+
responseBody = await postJson(TOKEN_URL, {
|
|
227
|
+
grant_type: "refresh_token",
|
|
228
|
+
client_id: CLIENT_ID,
|
|
229
|
+
refresh_token: credential.refresh,
|
|
230
|
+
});
|
|
231
|
+
} catch (error) {
|
|
232
|
+
throw new Error(`Anthropic token refresh request failed. details=${formatErrorDetails(error)}`);
|
|
233
|
+
}
|
|
234
|
+
const data = JSON.parse(responseBody) as {
|
|
235
|
+
refresh_token?: string;
|
|
236
|
+
access_token?: string;
|
|
237
|
+
expires_in?: number;
|
|
238
|
+
};
|
|
239
|
+
if (!data.access_token || !data.expires_in) {
|
|
240
|
+
throw new Error("Anthropic token refresh response did not contain complete OAuth credentials.");
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
type: "oauth",
|
|
244
|
+
refresh: data.refresh_token ?? credential.refresh,
|
|
245
|
+
access: data.access_token,
|
|
246
|
+
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
|
|
247
|
+
};
|
|
248
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zgltyq/pi-provider-claude",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
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",
|