pi-anthropic-oauth 0.3.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-anthropic-oauth",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Claude Pro/Max OAuth extension for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/auth.ts CHANGED
@@ -23,10 +23,48 @@ const CALLBACK_HOST = "127.0.0.1";
23
23
  const LOCAL_CALLBACK_TIMEOUT = 5 * 60 * 1000;
24
24
  const MAX_TOKEN_RETRIES = 2;
25
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`;
26
29
 
27
30
  export { USER_AGENT };
28
31
 
29
- 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
+ };
30
68
  type LocalAuthorization = {
31
69
  redirectUri: string;
32
70
  waitForCallback: () => Promise<string | null>;
@@ -45,55 +83,68 @@ export async function loginAnthropic(
45
83
 
46
84
  let authInput: string | null = null;
47
85
  let redirectUri = REDIRECT_URI;
86
+ const initiatedRedirectUris = new Set<string>();
48
87
 
88
+ let localAuthorization: LocalAuthorization | null = null;
49
89
  try {
50
- const localAuthorization = await createLocalAuthorization(state);
51
- redirectUri = localAuthorization.redirectUri;
90
+ localAuthorization = await createLocalAuthorization(state);
91
+ } catch {
92
+ // Failure to bind the callback server falls back to the paste flow.
93
+ }
52
94
 
53
- callbacks.onAuth({
54
- url: makeAuthorizeUrl(challenge, state, redirectUri),
55
- instructions:
56
- "Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.",
57
- });
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
+ });
58
106
 
59
- if (callbacks.onManualCodeInput) {
60
- let manualInput: string | undefined;
61
- let manualError: Error | undefined;
62
- const manualPromise = callbacks
63
- .onManualCodeInput()
64
- .then((input) => {
65
- manualInput = input;
66
- localAuthorization.cancel();
67
- })
68
- .catch((err) => {
69
- manualError =
70
- err instanceof Error ? err : new Error(String(err));
71
- localAuthorization.cancel();
72
- });
73
-
74
- const callbackResult = await localAuthorization.waitForCallback();
75
-
76
- if (manualError) throw manualError;
77
-
78
- if (callbackResult) {
79
- authInput = callbackResult;
80
- } else if (manualInput) {
81
- authInput = manualInput;
82
- }
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();
83
123
 
84
- if (!authInput) {
85
- await manualPromise;
86
124
  if (manualError) throw manualError;
87
- 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();
88
139
  }
89
- } else {
90
- authInput = await localAuthorization.waitForCallback();
91
140
  }
92
- } catch {
141
+ } finally {
142
+ localAuthorization?.cancel();
93
143
  }
94
144
 
95
145
  if (!authInput) {
96
146
  redirectUri = REDIRECT_URI;
147
+ initiatedRedirectUris.add(redirectUri);
97
148
  callbacks.onAuth({
98
149
  url: makeAuthorizeUrl(challenge, state, redirectUri),
99
150
  instructions:
@@ -104,10 +155,12 @@ export async function loginAnthropic(
104
155
  });
105
156
  }
106
157
 
107
- const parsed = parseAuthInput(authInput);
158
+ const parsed = parseAuthInput(authInput, initiatedRedirectUris);
108
159
  if (!parsed) throw new Error("Could not parse authorization callback input.");
109
160
  if (parsed.state !== state) throw new Error("OAuth state mismatch.");
110
161
 
162
+ const exchangeRedirectUri = parsed.redirectUri ?? redirectUri;
163
+
111
164
  const tokenResponse = await fetchWithRetry(
112
165
  TOKEN_URL,
113
166
  {
@@ -118,7 +171,7 @@ export async function loginAnthropic(
118
171
  client_id: CLIENT_ID,
119
172
  code: parsed.code,
120
173
  state: parsed.state,
121
- redirect_uri: redirectUri,
174
+ redirect_uri: exchangeRedirectUri,
122
175
  code_verifier: verifier,
123
176
  }),
124
177
  signal: callbacks.signal,
@@ -157,11 +210,12 @@ export async function refreshAnthropicToken(
157
210
  },
158
211
  "Token refresh",
159
212
  );
160
- } catch {
161
- if (credentials.expires > Date.now()) {
213
+ } catch (error) {
214
+ if (isTransientTokenError(error) && credentials.expires > Date.now()) {
162
215
  return { ...credentials, expires: Date.now() + 30_000 };
163
216
  }
164
- 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}`);
165
219
  }
166
220
 
167
221
  const data = (await response.json()) as {
@@ -210,28 +264,29 @@ async function fetchWithRetry(
210
264
 
211
265
  const bodyText = await response.text();
212
266
 
267
+ const failure = new TokenHttpError(
268
+ `${label} failed: ${response.status} ${bodyText}`,
269
+ response.status,
270
+ );
271
+
213
272
  const shouldRetry = response.headers.get("x-should-retry");
214
- if (shouldRetry === "false") {
215
- throw new Error(`${label} failed: ${response.status} ${bodyText}`);
216
- }
273
+ if (shouldRetry === "false") throw failure;
217
274
 
218
275
  if (
219
276
  attempt < MAX_TOKEN_RETRIES &&
220
- (response.status === 429 || response.status >= 500)
277
+ (response.status === 429 ||
278
+ (response.status >= 500 && response.status < 600))
221
279
  ) {
222
- const retryAfter = response.headers.get("retry-after");
223
- const delayMs = retryAfter
224
- ? Math.min(Number(retryAfter) * 1000, 30_000)
225
- : INITIAL_RETRY_DELAY_MS * 2 ** attempt;
280
+ const delayMs =
281
+ parseRetryAfter(response.headers.get("retry-after")) ??
282
+ INITIAL_RETRY_DELAY_MS * 2 ** attempt;
226
283
 
227
284
  await new Promise((resolve) => setTimeout(resolve, delayMs));
228
- lastError = new Error(
229
- `${label} failed: ${response.status} ${bodyText}`,
230
- );
285
+ lastError = failure;
231
286
  continue;
232
287
  }
233
288
 
234
- throw new Error(`${label} failed: ${response.status} ${bodyText}`);
289
+ throw failure;
235
290
  }
236
291
 
237
292
  throw lastError ?? new Error(`${label} failed after retries`);
@@ -250,6 +305,7 @@ async function createLocalAuthorization(
250
305
 
251
306
  return new Promise((resolve, reject) => {
252
307
  let done = false;
308
+ let settled = false;
253
309
  let timer: ReturnType<typeof setTimeout> | undefined;
254
310
  let complete!: (value: string | null) => void;
255
311
  const wait = new Promise<string | null>((innerResolve) => {
@@ -268,10 +324,7 @@ async function createLocalAuthorization(
268
324
  };
269
325
 
270
326
  server.on("request", (req, res) => {
271
- const url = new URL(
272
- req.url ?? "/",
273
- `http://${req.headers.host ?? "localhost"}`,
274
- );
327
+ const url = new URL(req.url ?? "/", LOCAL_REDIRECT_URI);
275
328
 
276
329
  if (url.pathname !== "/callback") {
277
330
  res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
@@ -290,7 +343,6 @@ async function createLocalAuthorization(
290
343
  if (gotState !== state) {
291
344
  res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
292
345
  res.end("Invalid state");
293
- finish(null);
294
346
  return;
295
347
  }
296
348
 
@@ -299,15 +351,25 @@ async function createLocalAuthorization(
299
351
  Connection: "close",
300
352
  });
301
353
  res.end(makeCallbackPage());
302
- finish(url.toString());
354
+ finish(`${LOCAL_REDIRECT_URI}?${url.searchParams.toString()}`);
303
355
  });
304
356
 
305
- 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
+ });
306
366
 
307
367
  server.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
368
+ settled = true;
308
369
  timer = setTimeout(() => finish(null), LOCAL_CALLBACK_TIMEOUT);
370
+ timer.unref?.();
309
371
  resolve({
310
- redirectUri: `http://localhost:${CALLBACK_PORT}/callback`,
372
+ redirectUri: LOCAL_REDIRECT_URI,
311
373
  waitForCallback: () => wait,
312
374
  cancel: () => finish(null),
313
375
  });
@@ -326,6 +388,10 @@ function makeCallbackPage(): string {
326
388
  </html>`;
327
389
  }
328
390
 
391
+ function settleGrace(): Promise<void> {
392
+ return new Promise((resolve) => setTimeout(resolve, MANUAL_SETTLE_GRACE_MS));
393
+ }
394
+
329
395
  async function generatePKCE(): Promise<{
330
396
  verifier: string;
331
397
  challenge: string;
@@ -351,14 +417,21 @@ function toBase64Url(bytes: Uint8Array): string {
351
417
  .replace(/=+$/g, "");
352
418
  }
353
419
 
354
- function parseAuthInput(input: string): ParsedAuthInput | null {
420
+ function parseAuthInput(
421
+ input: string,
422
+ initiatedRedirectUris: ReadonlySet<string>,
423
+ ): ParsedAuthInput | null {
355
424
  const text = input.trim();
356
425
 
357
426
  try {
358
427
  const url = new URL(text);
359
428
  const code = url.searchParams.get("code");
360
429
  const state = url.searchParams.get("state");
361
- 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
+ }
362
435
  } catch {}
363
436
 
364
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
@@ -70,7 +70,9 @@ export function buildAnthropicSystemPrompt(
70
70
  blocks.push({ type: "text", text: CLAUDE_CODE_IDENTITY });
71
71
  }
72
72
 
73
- const sanitized = systemPrompt ? sanitizeSystemText(systemPrompt) : "";
73
+ const sanitized = systemPrompt
74
+ ? sanitizeSystemText(sanitizeSurrogates(systemPrompt))
75
+ : "";
74
76
  if (sanitized) {
75
77
  blocks.push({
76
78
  type: "text",
@@ -93,10 +95,11 @@ export function sanitizeSystemText(
93
95
  return !PI_REMOVAL_ANCHORS.some((anchor) => paragraph.includes(anchor));
94
96
  });
95
97
 
96
- return filtered
97
- .join("\n\n")
98
- .replace(resolvePiRewritePattern(env), "Claude Code")
99
- .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();
100
103
  }
101
104
 
102
105
  function resolvePiRewritePattern(env: NodeJS.ProcessEnv): RegExp {
@@ -122,12 +125,30 @@ function compileCustomPiRewritePattern(value: string | undefined): RegExp {
122
125
 
123
126
  const parsed = parseRegexLiteral(pattern) ?? { source: pattern, flags: "" };
124
127
  const flags = parsed.flags.includes("g") ? parsed.flags : `${parsed.flags}g`;
128
+ let compiled: RegExp;
125
129
  try {
126
- return new RegExp(parsed.source, flags);
130
+ compiled = new RegExp(parsed.source, flags);
127
131
  } catch (error) {
128
132
  const message = error instanceof Error ? error.message : String(error);
129
133
  throw new Error(`Invalid ${PI_REWRITE_PATTERN_ENV}: ${message}`);
130
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;
131
152
  }
132
153
 
133
154
  function parseRegexLiteral(value: string): { source: string; flags: string } | undefined {
package/src/stream.ts CHANGED
@@ -36,6 +36,26 @@ const REQUIRED_BETAS = [
36
36
  "interleaved-thinking-2025-05-14",
37
37
  ] as const;
38
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
+
39
59
  function mapStopReason(reason: string | null | undefined): StopReason {
40
60
  switch (reason) {
41
61
  case "end_turn":
@@ -51,6 +71,43 @@ function mapStopReason(reason: string | null | undefined): StopReason {
51
71
  }
52
72
  }
53
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
+
54
111
  function headersToRecord(headers: Headers): Record<string, string> {
55
112
  const record: Record<string, string> = {};
56
113
  headers.forEach((value, key) => {
@@ -180,39 +237,22 @@ export function streamAnthropicOAuth(
180
237
  ];
181
238
  const requestedBudget =
182
239
  customBudget ?? defaultBudgets[options.reasoning] ?? 10240;
183
- const display = "summarized";
184
- const forceAdaptive = (
185
- model.compat as { forceAdaptiveThinking?: boolean } | undefined
186
- )?.forceAdaptiveThinking;
187
- const id = model.id.toLowerCase().replace(/\./g, "-");
188
- const adaptive =
189
- forceAdaptive === true ||
190
- (forceAdaptive !== false &&
191
- (/claude-(?:opus|sonnet|haiku|fable|mythos)-5(?:-|$)/.test(id) ||
192
- /claude-(?:opus|sonnet|haiku|fable|mythos)-4-(?:[6-9]|\d{2,})(?:-|$)/.test(
193
- id,
194
- )));
195
-
196
- if (adaptive) {
240
+ if (usesAdaptiveThinking(model)) {
197
241
  const mapped = model.thinkingLevelMap?.[options.reasoning];
198
242
  const effort =
199
243
  typeof mapped === "string"
200
244
  ? mapped
201
- : options.reasoning === "minimal" || options.reasoning === "low"
202
- ? "low"
203
- : options.reasoning === "medium"
204
- ? "medium"
205
- : options.reasoning === "high"
206
- ? "high"
207
- : "high";
208
- params.thinking = { type: "adaptive", display } as never;
209
- Object.assign(params, { output_config: { effort } });
210
- } else {
245
+ : (EFFORT_BY_REASONING[options.reasoning] ?? "high");
211
246
  params.thinking = {
212
- type: "enabled",
213
- budget_tokens: Math.min(requestedBudget, maxTokens - 1),
214
- display,
247
+ type: "adaptive",
248
+ display: "summarized",
215
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
+ }
216
256
  }
217
257
  }
218
258
 
@@ -413,42 +453,26 @@ export function streamAnthropicOAuth(
413
453
  }
414
454
 
415
455
  if (event.type === "message_delta") {
416
- output.stopReason = mapStopReason(event.delta.stop_reason);
417
- output.usage.input =
418
- (event.usage as { input_tokens?: number }).input_tokens ||
419
- output.usage.input;
420
- output.usage.output =
421
- (event.usage as { output_tokens?: number }).output_tokens ||
422
- output.usage.output;
423
- output.usage.cacheRead =
424
- (event.usage as { cache_read_input_tokens?: number })
425
- .cache_read_input_tokens || 0;
426
- output.usage.cacheWrite =
427
- (event.usage as { cache_creation_input_tokens?: number })
428
- .cache_creation_input_tokens || 0;
429
- const thinkingTokens = (
430
- event.usage as {
431
- output_tokens_details?: { thinking_tokens?: number };
432
- }
433
- ).output_tokens_details?.thinking_tokens;
434
- if (thinkingTokens != null) {
435
- output.usage.reasoning = thinkingTokens;
436
- }
437
- output.usage.totalTokens =
438
- output.usage.input +
439
- output.usage.output +
440
- output.usage.cacheRead +
441
- output.usage.cacheWrite;
442
- calculateCost(model, output.usage);
456
+ applyMessageDelta(
457
+ output,
458
+ model,
459
+ event.delta.stop_reason,
460
+ event.usage as MessageDeltaUsage | undefined,
461
+ );
443
462
  }
444
463
  }
445
464
 
446
465
  if (options?.signal?.aborted) throw new Error("Request aborted");
447
- stream.push({
448
- type: "done",
449
- reason: output.stopReason as "stop" | "length" | "toolUse",
450
- message: output,
451
- });
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
+ }
452
476
  stream.end();
453
477
  } catch (error) {
454
478
  for (const block of output.content as Array<{