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 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 text and rewrites standalone `Pi` / `pi` references in Pi's system prompt to `Claude Code`. The rewrite mode defaults to `aggressive`:
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.2.4",
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.80.8",
42
- "@earendil-works/pi-coding-agent": ">=0.80.8"
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.80.8",
46
- "@earendil-works/pi-coding-agent": "^0.80.8",
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 = "claude-code/2.1.97";
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
- type ParsedAuthInput = { code: string; state: string };
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
- const localAuthorization = await createLocalAuthorization(state);
50
- redirectUri = localAuthorization.redirectUri;
90
+ localAuthorization = await createLocalAuthorization(state);
91
+ } catch {
92
+ // Failure to bind the callback server falls back to the paste flow.
93
+ }
51
94
 
52
- callbacks.onAuth({
53
- url: makeAuthorizeUrl(challenge, state, redirectUri),
54
- instructions:
55
- "Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.",
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
- if (callbacks.onManualCodeInput) {
59
- let manualInput: string | undefined;
60
- let manualError: Error | undefined;
61
- const manualPromise = callbacks
62
- .onManualCodeInput()
63
- .then((input) => {
64
- manualInput = input;
65
- localAuthorization.cancel();
66
- })
67
- .catch((err) => {
68
- manualError =
69
- err instanceof Error ? err : new Error(String(err));
70
- localAuthorization.cancel();
71
- });
72
-
73
- const callbackResult = await localAuthorization.waitForCallback();
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
- if (manualInput) authInput = manualInput;
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
- } catch {
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: redirectUri,
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
- throw new Error("Token refresh failed and token has expired.");
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 || response.status >= 500)
277
+ (response.status === 429 ||
278
+ (response.status >= 500 && response.status < 600))
220
279
  ) {
221
- const retryAfter = response.headers.get("retry-after");
222
- const delayMs = retryAfter
223
- ? Math.min(Number(retryAfter) * 1000, 30_000)
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 = new Error(
228
- `${label} failed: ${response.status} ${bodyText}`,
229
- );
285
+ lastError = failure;
230
286
  continue;
231
287
  }
232
288
 
233
- throw new Error(`${label} failed: ${response.status} ${bodyText}`);
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.once("error", reject);
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: `http://localhost:${CALLBACK_PORT}/callback`,
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(input: string): ParsedAuthInput | null {
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) return { 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" && Array.isArray(last.content) && last.content.length > 0) {
250
- const lastBlock = last.content[last.content.length - 1] as { cache_control?: { type: string } };
251
- lastBlock.cache_control = { type: "ephemeral" };
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 Code, Anthropic's official CLI for 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
- type: "text",
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 ? sanitizeSystemText(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
- return filtered
84
- .join("\n\n")
85
- .replace(resolvePiRewritePattern(env), "Claude Code")
86
- .trim();
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
- return new RegExp(parsed.source, flags);
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
- type Context,
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: 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(context.messages, isOAuth, model),
216
+ messages: convertPiMessagesToAnthropic(transcript.messages, isOAuth, model),
148
217
  max_tokens: maxTokens,
149
218
  stream: true,
150
219
  };
151
220
 
152
- const system = buildAnthropicSystemPrompt(context.systemPrompt, isOAuth);
221
+ const system = buildAnthropicSystemPrompt(systemPrompt, isOAuth);
153
222
  if (system) params.system = system as never;
154
- if (context.tools?.length)
155
- params.tools = convertPiToolsToAnthropic(context.tools, isOAuth);
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
- params.thinking = {
173
- type: "enabled",
174
- budget_tokens: Math.min(requestedBudget, maxTokens - 1),
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
- context.tools,
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 Record<
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 Record<
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
- output.stopReason = mapStopReason(event.delta.stop_reason);
382
- output.usage.input =
383
- (event.usage as { input_tokens?: number }).input_tokens ||
384
- output.usage.input;
385
- output.usage.output =
386
- (event.usage as { output_tokens?: number }).output_tokens ||
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
- stream.push({
413
- type: "done",
414
- reason: output.stopReason as "stop" | "length" | "toolUse",
415
- message: output,
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
+ }