pi-anthropic-oauth 0.2.4 → 0.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 +9 -1
- package/package.json +5 -5
- package/src/auth.ts +140 -66
- package/src/convert.ts +23 -3
- package/src/prompt.ts +46 -12
- package/src/stream.ts +111 -52
- package/src/version.ts +24 -0
package/README.md
CHANGED
|
@@ -47,9 +47,17 @@ pi update npm:pi-anthropic-oauth
|
|
|
47
47
|
> [!NOTE]
|
|
48
48
|
> Anthropic auth changes are closely monitored for quick compatibility updates.
|
|
49
49
|
|
|
50
|
+
## Claude Code version override
|
|
51
|
+
|
|
52
|
+
Anthropic gates new models on the Claude Code client version (error `claude_code_version_too_old`). The version is sent in both the OAuth user agent and the billing header of the system prompt. Override it without reinstalling:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
PI_ANTHROPIC_OAUTH_CLAUDE_CODE_VERSION=2.1.280
|
|
56
|
+
```
|
|
57
|
+
|
|
50
58
|
## System prompt rewriting
|
|
51
59
|
|
|
52
|
-
When using Claude Pro/Max OAuth, the extension prepends Claude Code identity
|
|
60
|
+
When using Claude Pro/Max OAuth, the extension prepends the Claude Code billing-header and identity system blocks and rewrites standalone `Pi` / `pi` references in Pi's system prompt to `Claude Code`. The rewrite mode defaults to `aggressive`:
|
|
53
61
|
|
|
54
62
|
```bash
|
|
55
63
|
PI_ANTHROPIC_OAUTH_REWRITE_MODE=aggressive
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-anthropic-oauth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Claude Pro/Max OAuth extension for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,12 +38,12 @@
|
|
|
38
38
|
"@anthropic-ai/sdk": "^0.52.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"@earendil-works/pi-ai": ">=0.
|
|
42
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
41
|
+
"@earendil-works/pi-ai": ">=0.86.0",
|
|
42
|
+
"@earendil-works/pi-coding-agent": ">=0.86.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@earendil-works/pi-ai": "^0.
|
|
46
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
45
|
+
"@earendil-works/pi-ai": "^0.86.1",
|
|
46
|
+
"@earendil-works/pi-coding-agent": "^0.86.1",
|
|
47
47
|
"typescript": "^5.9.3"
|
|
48
48
|
}
|
|
49
49
|
}
|
package/src/auth.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
OAuthCredentials,
|
|
4
4
|
OAuthLoginCallbacks,
|
|
5
5
|
} from "@earendil-works/pi-ai";
|
|
6
|
+
import { makeClaudeCodeUserAgent } from "./version.js";
|
|
6
7
|
|
|
7
8
|
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
8
9
|
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
|
@@ -16,16 +17,54 @@ const SCOPES = [
|
|
|
16
17
|
"user:mcp_servers",
|
|
17
18
|
"user:file_upload",
|
|
18
19
|
].join(" ");
|
|
19
|
-
const USER_AGENT =
|
|
20
|
+
const USER_AGENT = makeClaudeCodeUserAgent();
|
|
20
21
|
const CALLBACK_PORT = 53692;
|
|
21
22
|
const CALLBACK_HOST = "127.0.0.1";
|
|
22
23
|
const LOCAL_CALLBACK_TIMEOUT = 5 * 60 * 1000;
|
|
23
24
|
const MAX_TOKEN_RETRIES = 2;
|
|
24
25
|
const INITIAL_RETRY_DELAY_MS = 5000;
|
|
26
|
+
const MAX_RETRY_DELAY_MS = 30_000;
|
|
27
|
+
const MANUAL_SETTLE_GRACE_MS = 10_000;
|
|
28
|
+
const LOCAL_REDIRECT_URI = `http://localhost:${CALLBACK_PORT}/callback`;
|
|
25
29
|
|
|
26
30
|
export { USER_AGENT };
|
|
27
31
|
|
|
28
|
-
|
|
32
|
+
class TokenHttpError extends Error {
|
|
33
|
+
constructor(
|
|
34
|
+
message: string,
|
|
35
|
+
readonly status: number,
|
|
36
|
+
) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "TokenHttpError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isTransientTokenError(error: unknown): boolean {
|
|
43
|
+
if (error instanceof TokenHttpError) {
|
|
44
|
+
return error.status === 429 ||
|
|
45
|
+
(error.status >= 500 && error.status < 600);
|
|
46
|
+
}
|
|
47
|
+
return error instanceof TypeError;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseRetryAfter(value: string | null): number | undefined {
|
|
51
|
+
if (!value) return undefined;
|
|
52
|
+
|
|
53
|
+
const seconds = Number(value);
|
|
54
|
+
if (Number.isFinite(seconds)) {
|
|
55
|
+
return Math.min(Math.max(seconds, 0) * 1000, MAX_RETRY_DELAY_MS);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const at = Date.parse(value);
|
|
59
|
+
if (Number.isNaN(at)) return undefined;
|
|
60
|
+
return Math.min(Math.max(at - Date.now(), 0), MAX_RETRY_DELAY_MS);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type ParsedAuthInput = {
|
|
64
|
+
code: string;
|
|
65
|
+
state: string;
|
|
66
|
+
redirectUri?: string;
|
|
67
|
+
};
|
|
29
68
|
type LocalAuthorization = {
|
|
30
69
|
redirectUri: string;
|
|
31
70
|
waitForCallback: () => Promise<string | null>;
|
|
@@ -44,55 +83,68 @@ export async function loginAnthropic(
|
|
|
44
83
|
|
|
45
84
|
let authInput: string | null = null;
|
|
46
85
|
let redirectUri = REDIRECT_URI;
|
|
86
|
+
const initiatedRedirectUris = new Set<string>();
|
|
47
87
|
|
|
88
|
+
let localAuthorization: LocalAuthorization | null = null;
|
|
48
89
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
90
|
+
localAuthorization = await createLocalAuthorization(state);
|
|
91
|
+
} catch {
|
|
92
|
+
// Failure to bind the callback server falls back to the paste flow.
|
|
93
|
+
}
|
|
51
94
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
95
|
+
try {
|
|
96
|
+
if (localAuthorization) {
|
|
97
|
+
const local = localAuthorization;
|
|
98
|
+
redirectUri = local.redirectUri;
|
|
99
|
+
initiatedRedirectUris.add(redirectUri);
|
|
100
|
+
|
|
101
|
+
callbacks.onAuth({
|
|
102
|
+
url: makeAuthorizeUrl(challenge, state, redirectUri),
|
|
103
|
+
instructions:
|
|
104
|
+
"Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.",
|
|
105
|
+
});
|
|
57
106
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (manualError) throw manualError;
|
|
76
|
-
|
|
77
|
-
if (callbackResult) {
|
|
78
|
-
authInput = callbackResult;
|
|
79
|
-
} else if (manualInput) {
|
|
80
|
-
authInput = manualInput;
|
|
81
|
-
}
|
|
107
|
+
if (callbacks.onManualCodeInput) {
|
|
108
|
+
let manualInput: string | undefined;
|
|
109
|
+
let manualError: Error | undefined;
|
|
110
|
+
const manualPromise = callbacks
|
|
111
|
+
.onManualCodeInput()
|
|
112
|
+
.then((input) => {
|
|
113
|
+
manualInput = input;
|
|
114
|
+
local.cancel();
|
|
115
|
+
})
|
|
116
|
+
.catch((err) => {
|
|
117
|
+
manualError =
|
|
118
|
+
err instanceof Error ? err : new Error(String(err));
|
|
119
|
+
local.cancel();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const callbackResult = await local.waitForCallback();
|
|
82
123
|
|
|
83
|
-
if (!authInput) {
|
|
84
|
-
await manualPromise;
|
|
85
124
|
if (manualError) throw manualError;
|
|
86
|
-
|
|
125
|
+
|
|
126
|
+
if (callbackResult) {
|
|
127
|
+
authInput = callbackResult;
|
|
128
|
+
} else if (manualInput) {
|
|
129
|
+
authInput = manualInput;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!authInput) {
|
|
133
|
+
await Promise.race([manualPromise, settleGrace()]);
|
|
134
|
+
if (manualError) throw manualError;
|
|
135
|
+
if (manualInput) authInput = manualInput;
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
authInput = await local.waitForCallback();
|
|
87
139
|
}
|
|
88
|
-
} else {
|
|
89
|
-
authInput = await localAuthorization.waitForCallback();
|
|
90
140
|
}
|
|
91
|
-
}
|
|
141
|
+
} finally {
|
|
142
|
+
localAuthorization?.cancel();
|
|
92
143
|
}
|
|
93
144
|
|
|
94
145
|
if (!authInput) {
|
|
95
146
|
redirectUri = REDIRECT_URI;
|
|
147
|
+
initiatedRedirectUris.add(redirectUri);
|
|
96
148
|
callbacks.onAuth({
|
|
97
149
|
url: makeAuthorizeUrl(challenge, state, redirectUri),
|
|
98
150
|
instructions:
|
|
@@ -103,10 +155,12 @@ export async function loginAnthropic(
|
|
|
103
155
|
});
|
|
104
156
|
}
|
|
105
157
|
|
|
106
|
-
const parsed = parseAuthInput(authInput);
|
|
158
|
+
const parsed = parseAuthInput(authInput, initiatedRedirectUris);
|
|
107
159
|
if (!parsed) throw new Error("Could not parse authorization callback input.");
|
|
108
160
|
if (parsed.state !== state) throw new Error("OAuth state mismatch.");
|
|
109
161
|
|
|
162
|
+
const exchangeRedirectUri = parsed.redirectUri ?? redirectUri;
|
|
163
|
+
|
|
110
164
|
const tokenResponse = await fetchWithRetry(
|
|
111
165
|
TOKEN_URL,
|
|
112
166
|
{
|
|
@@ -117,7 +171,7 @@ export async function loginAnthropic(
|
|
|
117
171
|
client_id: CLIENT_ID,
|
|
118
172
|
code: parsed.code,
|
|
119
173
|
state: parsed.state,
|
|
120
|
-
redirect_uri:
|
|
174
|
+
redirect_uri: exchangeRedirectUri,
|
|
121
175
|
code_verifier: verifier,
|
|
122
176
|
}),
|
|
123
177
|
signal: callbacks.signal,
|
|
@@ -156,11 +210,12 @@ export async function refreshAnthropicToken(
|
|
|
156
210
|
},
|
|
157
211
|
"Token refresh",
|
|
158
212
|
);
|
|
159
|
-
} catch {
|
|
160
|
-
if (credentials.expires > Date.now()) {
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (isTransientTokenError(error) && credentials.expires > Date.now()) {
|
|
161
215
|
return { ...credentials, expires: Date.now() + 30_000 };
|
|
162
216
|
}
|
|
163
|
-
|
|
217
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
218
|
+
throw new Error(`Token refresh failed: ${detail}`);
|
|
164
219
|
}
|
|
165
220
|
|
|
166
221
|
const data = (await response.json()) as {
|
|
@@ -209,28 +264,29 @@ async function fetchWithRetry(
|
|
|
209
264
|
|
|
210
265
|
const bodyText = await response.text();
|
|
211
266
|
|
|
267
|
+
const failure = new TokenHttpError(
|
|
268
|
+
`${label} failed: ${response.status} ${bodyText}`,
|
|
269
|
+
response.status,
|
|
270
|
+
);
|
|
271
|
+
|
|
212
272
|
const shouldRetry = response.headers.get("x-should-retry");
|
|
213
|
-
if (shouldRetry === "false")
|
|
214
|
-
throw new Error(`${label} failed: ${response.status} ${bodyText}`);
|
|
215
|
-
}
|
|
273
|
+
if (shouldRetry === "false") throw failure;
|
|
216
274
|
|
|
217
275
|
if (
|
|
218
276
|
attempt < MAX_TOKEN_RETRIES &&
|
|
219
|
-
(response.status === 429 ||
|
|
277
|
+
(response.status === 429 ||
|
|
278
|
+
(response.status >= 500 && response.status < 600))
|
|
220
279
|
) {
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
: INITIAL_RETRY_DELAY_MS * 2 ** attempt;
|
|
280
|
+
const delayMs =
|
|
281
|
+
parseRetryAfter(response.headers.get("retry-after")) ??
|
|
282
|
+
INITIAL_RETRY_DELAY_MS * 2 ** attempt;
|
|
225
283
|
|
|
226
284
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
227
|
-
lastError =
|
|
228
|
-
`${label} failed: ${response.status} ${bodyText}`,
|
|
229
|
-
);
|
|
285
|
+
lastError = failure;
|
|
230
286
|
continue;
|
|
231
287
|
}
|
|
232
288
|
|
|
233
|
-
throw
|
|
289
|
+
throw failure;
|
|
234
290
|
}
|
|
235
291
|
|
|
236
292
|
throw lastError ?? new Error(`${label} failed after retries`);
|
|
@@ -249,6 +305,7 @@ async function createLocalAuthorization(
|
|
|
249
305
|
|
|
250
306
|
return new Promise((resolve, reject) => {
|
|
251
307
|
let done = false;
|
|
308
|
+
let settled = false;
|
|
252
309
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
253
310
|
let complete!: (value: string | null) => void;
|
|
254
311
|
const wait = new Promise<string | null>((innerResolve) => {
|
|
@@ -267,10 +324,7 @@ async function createLocalAuthorization(
|
|
|
267
324
|
};
|
|
268
325
|
|
|
269
326
|
server.on("request", (req, res) => {
|
|
270
|
-
const url = new URL(
|
|
271
|
-
req.url ?? "/",
|
|
272
|
-
`http://${req.headers.host ?? "localhost"}`,
|
|
273
|
-
);
|
|
327
|
+
const url = new URL(req.url ?? "/", LOCAL_REDIRECT_URI);
|
|
274
328
|
|
|
275
329
|
if (url.pathname !== "/callback") {
|
|
276
330
|
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
@@ -289,7 +343,6 @@ async function createLocalAuthorization(
|
|
|
289
343
|
if (gotState !== state) {
|
|
290
344
|
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
|
291
345
|
res.end("Invalid state");
|
|
292
|
-
finish(null);
|
|
293
346
|
return;
|
|
294
347
|
}
|
|
295
348
|
|
|
@@ -298,15 +351,25 @@ async function createLocalAuthorization(
|
|
|
298
351
|
Connection: "close",
|
|
299
352
|
});
|
|
300
353
|
res.end(makeCallbackPage());
|
|
301
|
-
finish(url.toString());
|
|
354
|
+
finish(`${LOCAL_REDIRECT_URI}?${url.searchParams.toString()}`);
|
|
302
355
|
});
|
|
303
356
|
|
|
304
|
-
server.
|
|
357
|
+
server.on("error", (error) => {
|
|
358
|
+
if (!settled) {
|
|
359
|
+
settled = true;
|
|
360
|
+
finish(null);
|
|
361
|
+
reject(error);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
finish(null);
|
|
365
|
+
});
|
|
305
366
|
|
|
306
367
|
server.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
|
|
368
|
+
settled = true;
|
|
307
369
|
timer = setTimeout(() => finish(null), LOCAL_CALLBACK_TIMEOUT);
|
|
370
|
+
timer.unref?.();
|
|
308
371
|
resolve({
|
|
309
|
-
redirectUri:
|
|
372
|
+
redirectUri: LOCAL_REDIRECT_URI,
|
|
310
373
|
waitForCallback: () => wait,
|
|
311
374
|
cancel: () => finish(null),
|
|
312
375
|
});
|
|
@@ -325,6 +388,10 @@ function makeCallbackPage(): string {
|
|
|
325
388
|
</html>`;
|
|
326
389
|
}
|
|
327
390
|
|
|
391
|
+
function settleGrace(): Promise<void> {
|
|
392
|
+
return new Promise((resolve) => setTimeout(resolve, MANUAL_SETTLE_GRACE_MS));
|
|
393
|
+
}
|
|
394
|
+
|
|
328
395
|
async function generatePKCE(): Promise<{
|
|
329
396
|
verifier: string;
|
|
330
397
|
challenge: string;
|
|
@@ -350,14 +417,21 @@ function toBase64Url(bytes: Uint8Array): string {
|
|
|
350
417
|
.replace(/=+$/g, "");
|
|
351
418
|
}
|
|
352
419
|
|
|
353
|
-
function parseAuthInput(
|
|
420
|
+
function parseAuthInput(
|
|
421
|
+
input: string,
|
|
422
|
+
initiatedRedirectUris: ReadonlySet<string>,
|
|
423
|
+
): ParsedAuthInput | null {
|
|
354
424
|
const text = input.trim();
|
|
355
425
|
|
|
356
426
|
try {
|
|
357
427
|
const url = new URL(text);
|
|
358
428
|
const code = url.searchParams.get("code");
|
|
359
429
|
const state = url.searchParams.get("state");
|
|
360
|
-
if (code && state)
|
|
430
|
+
if (code && state) {
|
|
431
|
+
const redirectUri = `${url.origin}${url.pathname}`;
|
|
432
|
+
if (!initiatedRedirectUris.has(redirectUri)) return null;
|
|
433
|
+
return { code, state, redirectUri };
|
|
434
|
+
}
|
|
361
435
|
} catch {}
|
|
362
436
|
|
|
363
437
|
const split = text.split("#");
|
package/src/convert.ts
CHANGED
|
@@ -245,10 +245,30 @@ export function convertPiMessagesToAnthropic(
|
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
// The loop flushes pending tool uses before the next message. Flush once more
|
|
249
|
+
// so a history ending on an unresolved tool call is still valid.
|
|
250
|
+
flushPendingToolResults();
|
|
251
|
+
|
|
252
|
+
// Anthropic prompt caching requires an explicit breakpoint. Plain-text user
|
|
253
|
+
// turns must be promoted to a block so there is somewhere to attach it.
|
|
248
254
|
const last = params.at(-1);
|
|
249
|
-
if (last?.role === "user"
|
|
250
|
-
|
|
251
|
-
|
|
255
|
+
if (last?.role === "user") {
|
|
256
|
+
if (typeof last.content === "string") {
|
|
257
|
+
if (last.content) {
|
|
258
|
+
last.content = [
|
|
259
|
+
{
|
|
260
|
+
type: "text",
|
|
261
|
+
text: last.content,
|
|
262
|
+
cache_control: { type: "ephemeral" },
|
|
263
|
+
},
|
|
264
|
+
];
|
|
265
|
+
}
|
|
266
|
+
} else if (last.content.length > 0) {
|
|
267
|
+
const lastBlock = last.content[last.content.length - 1] as {
|
|
268
|
+
cache_control?: { type: string };
|
|
269
|
+
};
|
|
270
|
+
lastBlock.cache_control = { type: "ephemeral" };
|
|
271
|
+
}
|
|
252
272
|
}
|
|
253
273
|
|
|
254
274
|
return params;
|
package/src/prompt.ts
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getClaudeCodeVersion } from "./version.js";
|
|
3
|
+
|
|
1
4
|
const CLAUDE_CODE_IDENTITY =
|
|
2
|
-
"You are Claude
|
|
5
|
+
"You are a Claude agent, built on Anthropic's Claude Agent SDK.";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Anthropic's OAuth classifier rejects premium-model requests that do not
|
|
9
|
+
* open with a Claude Code billing-header system block. The `cch` and
|
|
10
|
+
* `cc_prompt_id` keys must be present, but their values are not validated,
|
|
11
|
+
* and the block must not carry `cache_control`.
|
|
12
|
+
*/
|
|
13
|
+
export function buildClaudeCodeBillingBlock(): MessageContentBlock {
|
|
14
|
+
return {
|
|
15
|
+
type: "text",
|
|
16
|
+
text: `x-anthropic-billing-header: cc_version=${getClaudeCodeVersion()}; cc_entrypoint=cli; cch=00000; cc_prompt_id=${randomUUID()}`,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
3
19
|
const PI_REMOVAL_ANCHORS = [
|
|
4
20
|
"pi-coding-agent",
|
|
5
21
|
"@earendil-works/pi-coding-agent",
|
|
@@ -50,14 +66,13 @@ export function buildAnthropicSystemPrompt(
|
|
|
50
66
|
const blocks: MessageContentBlock[] = [];
|
|
51
67
|
|
|
52
68
|
if (isOAuth) {
|
|
53
|
-
blocks.push(
|
|
54
|
-
|
|
55
|
-
text: CLAUDE_CODE_IDENTITY,
|
|
56
|
-
cache_control: { type: "ephemeral" },
|
|
57
|
-
});
|
|
69
|
+
blocks.push(buildClaudeCodeBillingBlock());
|
|
70
|
+
blocks.push({ type: "text", text: CLAUDE_CODE_IDENTITY });
|
|
58
71
|
}
|
|
59
72
|
|
|
60
|
-
const sanitized = systemPrompt
|
|
73
|
+
const sanitized = systemPrompt
|
|
74
|
+
? sanitizeSystemText(sanitizeSurrogates(systemPrompt))
|
|
75
|
+
: "";
|
|
61
76
|
if (sanitized) {
|
|
62
77
|
blocks.push({
|
|
63
78
|
type: "text",
|
|
@@ -80,10 +95,11 @@ export function sanitizeSystemText(
|
|
|
80
95
|
return !PI_REMOVAL_ANCHORS.some((anchor) => paragraph.includes(anchor));
|
|
81
96
|
});
|
|
82
97
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
98
|
+
const filteredText = filtered.join("\n\n");
|
|
99
|
+
const rewritePattern = resolvePiRewritePattern(env);
|
|
100
|
+
rejectZeroLengthMatches(rewritePattern, filteredText);
|
|
101
|
+
|
|
102
|
+
return filteredText.replace(rewritePattern, "Claude Code").trim();
|
|
87
103
|
}
|
|
88
104
|
|
|
89
105
|
function resolvePiRewritePattern(env: NodeJS.ProcessEnv): RegExp {
|
|
@@ -109,12 +125,30 @@ function compileCustomPiRewritePattern(value: string | undefined): RegExp {
|
|
|
109
125
|
|
|
110
126
|
const parsed = parseRegexLiteral(pattern) ?? { source: pattern, flags: "" };
|
|
111
127
|
const flags = parsed.flags.includes("g") ? parsed.flags : `${parsed.flags}g`;
|
|
128
|
+
let compiled: RegExp;
|
|
112
129
|
try {
|
|
113
|
-
|
|
130
|
+
compiled = new RegExp(parsed.source, flags);
|
|
114
131
|
} catch (error) {
|
|
115
132
|
const message = error instanceof Error ? error.message : String(error);
|
|
116
133
|
throw new Error(`Invalid ${PI_REWRITE_PATTERN_ENV}: ${message}`);
|
|
117
134
|
}
|
|
135
|
+
|
|
136
|
+
rejectZeroLengthMatches(compiled, "");
|
|
137
|
+
return compiled;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function rejectZeroLengthMatches(pattern: RegExp, text: string): void {
|
|
141
|
+
pattern.lastIndex = 0;
|
|
142
|
+
let match: RegExpExecArray | null;
|
|
143
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
144
|
+
if (match[0].length === 0) {
|
|
145
|
+
pattern.lastIndex = 0;
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Invalid ${PI_REWRITE_PATTERN_ENV}: the pattern can match without consuming text. Use (?!) to disable rewriting.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
pattern.lastIndex = 0;
|
|
118
152
|
}
|
|
119
153
|
|
|
120
154
|
function parseRegexLiteral(value: string): { source: string; flags: string } | undefined {
|
package/src/stream.ts
CHANGED
|
@@ -5,11 +5,15 @@ import {
|
|
|
5
5
|
type AssistantMessage,
|
|
6
6
|
type AssistantMessageEventStream,
|
|
7
7
|
calculateCost,
|
|
8
|
-
|
|
8
|
+
collapseSystemMessages,
|
|
9
9
|
createAssistantMessageEventStream,
|
|
10
|
+
getCurrentSystemPrompt,
|
|
11
|
+
getCurrentTools,
|
|
12
|
+
type JsonObject,
|
|
10
13
|
type Model,
|
|
11
14
|
type SimpleStreamOptions,
|
|
12
15
|
type StopReason,
|
|
16
|
+
type TranscriptContext,
|
|
13
17
|
} from "@earendil-works/pi-ai";
|
|
14
18
|
import { isClaudeOAuthAccessToken, USER_AGENT } from "./auth.js";
|
|
15
19
|
import {
|
|
@@ -32,6 +36,26 @@ const REQUIRED_BETAS = [
|
|
|
32
36
|
"interleaved-thinking-2025-05-14",
|
|
33
37
|
] as const;
|
|
34
38
|
|
|
39
|
+
const LEGACY_THINKING_MODEL =
|
|
40
|
+
/^claude-(?:opus|sonnet|haiku)-4-[0-5](?:-|$)|^claude-[0-3][-.]/;
|
|
41
|
+
const MIN_THINKING_BUDGET = 1024;
|
|
42
|
+
const EFFORT_BY_REASONING: Record<string, string> = {
|
|
43
|
+
minimal: "low",
|
|
44
|
+
low: "low",
|
|
45
|
+
medium: "medium",
|
|
46
|
+
high: "high",
|
|
47
|
+
xhigh: "xhigh",
|
|
48
|
+
max: "max",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function usesAdaptiveThinking(model: Model<Api>): boolean {
|
|
52
|
+
const forced = (
|
|
53
|
+
model.compat as { forceAdaptiveThinking?: boolean } | undefined
|
|
54
|
+
)?.forceAdaptiveThinking;
|
|
55
|
+
if (typeof forced === "boolean") return forced;
|
|
56
|
+
return !LEGACY_THINKING_MODEL.test(model.id.toLowerCase().replace(/\./g, "-"));
|
|
57
|
+
}
|
|
58
|
+
|
|
35
59
|
function mapStopReason(reason: string | null | undefined): StopReason {
|
|
36
60
|
switch (reason) {
|
|
37
61
|
case "end_turn":
|
|
@@ -47,6 +71,43 @@ function mapStopReason(reason: string | null | undefined): StopReason {
|
|
|
47
71
|
}
|
|
48
72
|
}
|
|
49
73
|
|
|
74
|
+
type MessageDeltaUsage = {
|
|
75
|
+
input_tokens?: number | null;
|
|
76
|
+
output_tokens?: number | null;
|
|
77
|
+
cache_read_input_tokens?: number | null;
|
|
78
|
+
cache_creation_input_tokens?: number | null;
|
|
79
|
+
output_tokens_details?: { thinking_tokens?: number | null };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export function applyMessageDelta(
|
|
83
|
+
output: AssistantMessage,
|
|
84
|
+
model: Model<Api>,
|
|
85
|
+
stopReason: string | null | undefined,
|
|
86
|
+
usage: MessageDeltaUsage | undefined,
|
|
87
|
+
): void {
|
|
88
|
+
if (stopReason != null) output.stopReason = mapStopReason(stopReason);
|
|
89
|
+
|
|
90
|
+
if (usage) {
|
|
91
|
+
if (usage.input_tokens != null) output.usage.input = usage.input_tokens;
|
|
92
|
+
if (usage.output_tokens != null) output.usage.output = usage.output_tokens;
|
|
93
|
+
if (usage.cache_read_input_tokens != null) {
|
|
94
|
+
output.usage.cacheRead = usage.cache_read_input_tokens;
|
|
95
|
+
}
|
|
96
|
+
if (usage.cache_creation_input_tokens != null) {
|
|
97
|
+
output.usage.cacheWrite = usage.cache_creation_input_tokens;
|
|
98
|
+
}
|
|
99
|
+
const thinkingTokens = usage.output_tokens_details?.thinking_tokens;
|
|
100
|
+
if (thinkingTokens != null) output.usage.reasoning = thinkingTokens;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
output.usage.totalTokens =
|
|
104
|
+
output.usage.input +
|
|
105
|
+
output.usage.output +
|
|
106
|
+
output.usage.cacheRead +
|
|
107
|
+
output.usage.cacheWrite;
|
|
108
|
+
calculateCost(model, output.usage);
|
|
109
|
+
}
|
|
110
|
+
|
|
50
111
|
function headersToRecord(headers: Headers): Record<string, string> {
|
|
51
112
|
const record: Record<string, string> = {};
|
|
52
113
|
headers.forEach((value, key) => {
|
|
@@ -94,11 +155,19 @@ function makeDefaultHeaders(
|
|
|
94
155
|
|
|
95
156
|
export function streamAnthropicOAuth(
|
|
96
157
|
model: Model<Api>,
|
|
97
|
-
context:
|
|
158
|
+
context: TranscriptContext,
|
|
98
159
|
options?: SimpleStreamOptions,
|
|
99
160
|
): AssistantMessageEventStream {
|
|
100
161
|
const stream = createAssistantMessageEventStream();
|
|
101
162
|
|
|
163
|
+
// Anthropic has no mid-conversation system messages: fold system-message
|
|
164
|
+
// patches into the leading system message, then read the current prompt and
|
|
165
|
+
// tool declarations from the transcript (pi >= 0.86 no longer provides
|
|
166
|
+
// context.systemPrompt / context.tools).
|
|
167
|
+
const transcript = collapseSystemMessages(context);
|
|
168
|
+
const tools = getCurrentTools(transcript.messages);
|
|
169
|
+
const systemPrompt = getCurrentSystemPrompt(transcript.messages);
|
|
170
|
+
|
|
102
171
|
void (async () => {
|
|
103
172
|
const output: AssistantMessage = {
|
|
104
173
|
role: "assistant",
|
|
@@ -144,15 +213,15 @@ export function streamAnthropicOAuth(
|
|
|
144
213
|
|
|
145
214
|
const params: MessageCreateParamsStreaming = {
|
|
146
215
|
model: model.id,
|
|
147
|
-
messages: convertPiMessagesToAnthropic(
|
|
216
|
+
messages: convertPiMessagesToAnthropic(transcript.messages, isOAuth, model),
|
|
148
217
|
max_tokens: maxTokens,
|
|
149
218
|
stream: true,
|
|
150
219
|
};
|
|
151
220
|
|
|
152
|
-
const system = buildAnthropicSystemPrompt(
|
|
221
|
+
const system = buildAnthropicSystemPrompt(systemPrompt, isOAuth);
|
|
153
222
|
if (system) params.system = system as never;
|
|
154
|
-
if (
|
|
155
|
-
params.tools = convertPiToolsToAnthropic(
|
|
223
|
+
if (tools.length)
|
|
224
|
+
params.tools = convertPiToolsToAnthropic(tools, isOAuth);
|
|
156
225
|
|
|
157
226
|
if (options?.reasoning && model.reasoning && maxTokens > 1) {
|
|
158
227
|
const defaultBudgets: Record<string, number> = {
|
|
@@ -168,11 +237,23 @@ export function streamAnthropicOAuth(
|
|
|
168
237
|
];
|
|
169
238
|
const requestedBudget =
|
|
170
239
|
customBudget ?? defaultBudgets[options.reasoning] ?? 10240;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
240
|
+
if (usesAdaptiveThinking(model)) {
|
|
241
|
+
const mapped = model.thinkingLevelMap?.[options.reasoning];
|
|
242
|
+
const effort =
|
|
243
|
+
typeof mapped === "string"
|
|
244
|
+
? mapped
|
|
245
|
+
: (EFFORT_BY_REASONING[options.reasoning] ?? "high");
|
|
246
|
+
params.thinking = {
|
|
247
|
+
type: "adaptive",
|
|
248
|
+
display: "summarized",
|
|
249
|
+
} as never;
|
|
250
|
+
Object.assign(params, { output_config: { effort } });
|
|
251
|
+
} else {
|
|
252
|
+
const budget = Math.min(requestedBudget, maxTokens - 1);
|
|
253
|
+
if (budget >= MIN_THINKING_BUDGET) {
|
|
254
|
+
params.thinking = { type: "enabled", budget_tokens: budget };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
176
257
|
}
|
|
177
258
|
|
|
178
259
|
// Raw stream instead of the MessageStream helper: MessageStream
|
|
@@ -268,7 +349,7 @@ export function streamAnthropicOAuth(
|
|
|
268
349
|
name: isOAuth
|
|
269
350
|
? fromClaudeCodeToolName(
|
|
270
351
|
event.content_block.name,
|
|
271
|
-
|
|
352
|
+
tools,
|
|
272
353
|
)
|
|
273
354
|
: event.content_block.name,
|
|
274
355
|
arguments: {},
|
|
@@ -322,10 +403,7 @@ export function streamAnthropicOAuth(
|
|
|
322
403
|
) {
|
|
323
404
|
block.partialJson += event.delta.partial_json;
|
|
324
405
|
try {
|
|
325
|
-
block.arguments = JSON.parse(block.partialJson) as
|
|
326
|
-
string,
|
|
327
|
-
unknown
|
|
328
|
-
>;
|
|
406
|
+
block.arguments = JSON.parse(block.partialJson) as JsonObject;
|
|
329
407
|
} catch {}
|
|
330
408
|
stream.push({
|
|
331
409
|
type: "toolcall_delta",
|
|
@@ -361,10 +439,7 @@ export function streamAnthropicOAuth(
|
|
|
361
439
|
});
|
|
362
440
|
} else if (block.type === "toolCall") {
|
|
363
441
|
try {
|
|
364
|
-
block.arguments = JSON.parse(block.partialJson) as
|
|
365
|
-
string,
|
|
366
|
-
unknown
|
|
367
|
-
>;
|
|
442
|
+
block.arguments = JSON.parse(block.partialJson) as JsonObject;
|
|
368
443
|
} catch {}
|
|
369
444
|
delete (block as { partialJson?: string }).partialJson;
|
|
370
445
|
stream.push({
|
|
@@ -378,42 +453,26 @@ export function streamAnthropicOAuth(
|
|
|
378
453
|
}
|
|
379
454
|
|
|
380
455
|
if (event.type === "message_delta") {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
output.usage.output;
|
|
388
|
-
output.usage.cacheRead =
|
|
389
|
-
(event.usage as { cache_read_input_tokens?: number })
|
|
390
|
-
.cache_read_input_tokens || 0;
|
|
391
|
-
output.usage.cacheWrite =
|
|
392
|
-
(event.usage as { cache_creation_input_tokens?: number })
|
|
393
|
-
.cache_creation_input_tokens || 0;
|
|
394
|
-
const thinkingTokens = (
|
|
395
|
-
event.usage as {
|
|
396
|
-
output_tokens_details?: { thinking_tokens?: number };
|
|
397
|
-
}
|
|
398
|
-
).output_tokens_details?.thinking_tokens;
|
|
399
|
-
if (thinkingTokens != null) {
|
|
400
|
-
output.usage.reasoning = thinkingTokens;
|
|
401
|
-
}
|
|
402
|
-
output.usage.totalTokens =
|
|
403
|
-
output.usage.input +
|
|
404
|
-
output.usage.output +
|
|
405
|
-
output.usage.cacheRead +
|
|
406
|
-
output.usage.cacheWrite;
|
|
407
|
-
calculateCost(model, output.usage);
|
|
456
|
+
applyMessageDelta(
|
|
457
|
+
output,
|
|
458
|
+
model,
|
|
459
|
+
event.delta.stop_reason,
|
|
460
|
+
event.usage as MessageDeltaUsage | undefined,
|
|
461
|
+
);
|
|
408
462
|
}
|
|
409
463
|
}
|
|
410
464
|
|
|
411
465
|
if (options?.signal?.aborted) throw new Error("Request aborted");
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
466
|
+
if (output.stopReason === "error") {
|
|
467
|
+
output.errorMessage ??= "Unrecognized stop reason from the API.";
|
|
468
|
+
stream.push({ type: "error", reason: "error", error: output });
|
|
469
|
+
} else {
|
|
470
|
+
stream.push({
|
|
471
|
+
type: "done",
|
|
472
|
+
reason: output.stopReason as "stop" | "length" | "toolUse",
|
|
473
|
+
message: output,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
417
476
|
stream.end();
|
|
418
477
|
} catch (error) {
|
|
419
478
|
for (const block of output.content as Array<{
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const CLAUDE_CODE_VERSION_ENV = "PI_ANTHROPIC_OAUTH_CLAUDE_CODE_VERSION";
|
|
2
|
+
const DEFAULT_CLAUDE_CODE_VERSION = "2.1.280";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Claude Code version advertised to the API (user agent and billing header).
|
|
6
|
+
*
|
|
7
|
+
* Anthropic gates new models on the client version; override with
|
|
8
|
+
* PI_ANTHROPIC_OAUTH_CLAUDE_CODE_VERSION when a newer one is required.
|
|
9
|
+
*/
|
|
10
|
+
export function getClaudeCodeVersion(
|
|
11
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
12
|
+
): string {
|
|
13
|
+
return env[CLAUDE_CODE_VERSION_ENV]?.trim() || DEFAULT_CLAUDE_CODE_VERSION;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* User agent matching the current Claude Code CLI client. The classifier
|
|
18
|
+
* rejects the older `claude-code/<ver>` form for premium models.
|
|
19
|
+
*/
|
|
20
|
+
export function makeClaudeCodeUserAgent(
|
|
21
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
22
|
+
): string {
|
|
23
|
+
return `claude-cli/${getClaudeCodeVersion(env)} (external, cli)`;
|
|
24
|
+
}
|