@oh-my-pi/pi-ai 18.4.9 → 18.4.10

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/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.4.10] - 2026-10-02
6
+
7
+ ### Fixed
8
+
9
+ - Factory Droid login now reports the account's organization error instead of accepting a credential that every request rejects ([#14032](https://github.com/can1357/oh-my-pi/issues/14032)).
10
+ - Fixed Cursor turns being aborted with "Provider stream stalled while waiting for the next event" right after a long local tool finished; the provider now gets a full idle window once local tool work completes ([#13682](https://github.com/can1357/oh-my-pi/pull/13682) by [@eggpeat](https://github.com/eggpeat))
11
+ - Tool calls whose final argument JSON is cut off or followed by trailing text are no longer executed from an auto-closed preview. OpenAI Completions, Anthropic, Bedrock, Responses, Codex, Devin, Ollama, Apple, Cursor, GitLab Duo, and the in-band JSON dialects now give such a call the existing parse-error arguments, so the tool is not run and the model receives the parse error and can resend the call ([#13868](https://github.com/can1357/oh-my-pi/pull/13868) by [@alphastorm](https://github.com/alphastorm))
12
+ - Fixed Cursor "prepaid balance is used up" (`USAGE_PRICING_REQUIRED`) failures repeating on the same account instead of rotating to a sibling Cursor credential ([#14053](https://github.com/can1357/oh-my-pi/issues/14053))
13
+ - Sessions no longer get stuck on `400 string_above_max_length` after a model writes its whole tool invocation into the tool name. Tool calls with blank names, names longer than 128 characters, or names containing whitespace or control characters are dropped from replayed history, together with their tool results. This also applies when OpenAI Responses replays its stored native history ([#13985](https://github.com/can1357/oh-my-pi/pull/13985) by [@Xytronix](https://github.com/Xytronix)).
14
+ - Fixed Bedrock Converse requests failing with a "bound to a different conversation" 400 after the system prompt changed under signed thinking: the request is retried once without replayed reasoning ([#14019](https://github.com/can1357/oh-my-pi/pull/14019) by [@nick-maderight](https://github.com/nick-maderight))
15
+
5
16
  ## [18.4.9] - 2026-10-01
6
17
 
7
18
  ### Fixed
@@ -1,4 +1,11 @@
1
1
  import type { Api, AssistantMessage, Message, Model } from "../types.js";
2
+ /**
3
+ * Whether a tool-call name cannot belong to any declared tool: missing, empty,
4
+ * longer than OpenAI's 128-character replay limit, or containing whitespace or
5
+ * control characters (tool schemas never allow them; their presence means the
6
+ * name slot carries invocation text).
7
+ */
8
+ export declare function isMalformedToolCallName(name: unknown): boolean;
2
9
  /**
3
10
  * Normalize tool call ID for cross-provider compatibility.
4
11
  * OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
@@ -2,6 +2,12 @@ import type { AssistantMessage, AssistantMessageEvent } from "../types.js";
2
2
  /** Anything a stream watchdog can consult for in-flight consumer-side local work. */
3
3
  export interface LocalWorkSource {
4
4
  readonly hasPendingLocalWork: boolean;
5
+ /**
6
+ * Epoch ms at which tracked local work last drained to zero, or 0 if none
7
+ * has completed. The provider cannot answer until it receives the local
8
+ * result, so idle watchdogs measure provider silence from this instant.
9
+ */
10
+ readonly localWorkSettledAt: number;
5
11
  }
6
12
  export declare class EventStream<T, R = T> implements AsyncIterable<T> {
7
13
  #private;
@@ -35,6 +41,8 @@ export declare class EventStream<T, R = T> implements AsyncIterable<T> {
35
41
  result(): Promise<R>;
36
42
  /** True while local work tracked via {@link trackLocalWork} — on this stream or a forwarded delegate — is pending. */
37
43
  get hasPendingLocalWork(): boolean;
44
+ /** Latest {@link LocalWorkSource.localWorkSettledAt} across this stream and a forwarded delegate. */
45
+ get localWorkSettledAt(): number;
38
46
  /**
39
47
  * Count `source`'s pending local work as this stream's own. Used when this
40
48
  * stream forwards another's events (Cursor discovered-id retry) so the
@@ -1,3 +1,4 @@
1
+ import type { LocalWorkSource } from "./event-stream.js";
1
2
  /**
2
3
  * Returns the idle timeout used for provider streaming transports.
3
4
  *
@@ -86,15 +87,17 @@ export interface IdleTimeoutIteratorOptions {
86
87
  */
87
88
  isProgressItem?: (item: unknown) => boolean;
88
89
  /**
89
- * Reports consumer-side local work in flight for the stream: the provider
90
- * transport is waiting on a server-requested local tool bridge (e.g. the
91
- * Cursor exec channel) before anything can flow upstream again. While it
92
- * returns true, an expired idle / first-item deadline slides forward
93
- * instead of aborting — the silence is ours, not a provider stall. The
94
- * watchdog re-arms with a full budget once the local work completes, so a
95
- * provider that stalls afterwards is still caught.
90
+ * Consumer-side local work in flight for the stream: the provider transport
91
+ * is waiting on a server-requested local tool bridge (e.g. the Cursor exec
92
+ * channel) before anything can flow upstream again. While work is pending,
93
+ * an expired idle / first-item deadline slides forward instead of aborting —
94
+ * the silence is ours, not a provider stall. The provider cannot respond
95
+ * before it receives the local result, so both deadlines are measured from
96
+ * no earlier than `localWorkSettledAt`: a tool that finishes just before the
97
+ * deadline gets a full budget, and a provider that stalls afterwards is
98
+ * still caught.
96
99
  */
97
- hasPendingLocalWork?: () => boolean;
100
+ localWork?: LocalWorkSource;
98
101
  /**
99
102
  * Cancel iteration as soon as this signal aborts. Required for caller-driven
100
103
  * cancellation (ESC) when the underlying transport does not surface signal
@@ -0,0 +1,9 @@
1
+ import type { ToolCall } from "../types.js";
2
+ /** Final arguments must not reuse the streaming parser's auto-closed preview. */
3
+ export declare function parseToolCallArguments(json: string | undefined): ToolCall["arguments"];
4
+ /** Longest raw argument text kept in the parse-error diagnostic. */
5
+ export declare const INVALID_ARGUMENTS_RAW_LIMIT = 512;
6
+ /** Bound raw argument text for a diagnostic. Text this function already bounded is returned unchanged. */
7
+ export declare function boundRawToolArguments(raw: string): string;
8
+ /** Preserve a bounded diagnostic, never any partially recovered executable keys. */
9
+ export declare function invalidToolCallArguments(raw: string, error: unknown): ToolCall["arguments"];
@@ -63,6 +63,22 @@ export declare function sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(it
63
63
  * Drop hidden-only fallback assistant replay after a native Responses snapshot is rejected.
64
64
  */
65
65
  export declare function sanitizeOpenAIResponsesAssistantFallbackItemsForReplay(items: ResponseInput): ResponseInput;
66
+ /**
67
+ * Drop each native tool call with a malformed name and only the matching
68
+ * `function_call_output` / `custom_tool_call_output` in the same window.
69
+ *
70
+ * `call_id` is not unique across a snapshot. Pair the same way
71
+ * `sanitizeMalformedToolCalls` does for transcript messages: a per-id FIFO of
72
+ * malformed-ness, keyed by function vs custom, cleared at user/developer and
73
+ * other non-result client boundaries so a missing result cannot eat the next
74
+ * window's output. Returns `items` itself when nothing is malformed.
75
+ */
76
+ export declare function dropMalformedOpenAIResponsesToolCalls<T extends {
77
+ type?: unknown;
78
+ call_id?: unknown;
79
+ name?: unknown;
80
+ role?: unknown;
81
+ }>(items: readonly T[]): T[];
66
82
  export declare function createOpenAIResponsesHistoryPayload(provider: string, items: Array<Record<string, unknown>>, incremental?: boolean): OpenAIResponsesHistoryPayload;
67
83
  export declare function getOpenAIResponsesHistoryPayload(providerPayload: ProviderPayload | undefined, currentProvider: string, fallbackProvider?: string): OpenAIResponsesHistoryPayload | undefined;
68
84
  export declare function getOpenAIResponsesHistoryItems(providerPayload: ProviderPayload | undefined, currentProvider: string, fallbackProvider?: string): Array<Record<string, unknown>> | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.4.9",
3
+ "version": "18.4.10",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.4.9",
159
- "@oh-my-pi/pi-catalog": "18.4.9",
160
- "@oh-my-pi/pi-natives": "18.4.9",
161
- "@oh-my-pi/pi-utils": "18.4.9",
162
- "@oh-my-pi/pi-wire": "18.4.9"
158
+ "@oh-my-pi/omptype": "18.4.10",
159
+ "@oh-my-pi/pi-catalog": "18.4.10",
160
+ "@oh-my-pi/pi-natives": "18.4.10",
161
+ "@oh-my-pi/pi-utils": "18.4.10",
162
+ "@oh-my-pi/pi-wire": "18.4.10"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -1,5 +1,6 @@
1
1
  import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
2
2
  import type { Message, ToolCall } from "../types";
3
+ import { parseToolCallArguments } from "../utils/tool-call-arguments";
3
4
  import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
4
5
  import dialectPrompt from "./deepseek.md" with { type: "text" };
5
6
  import { assistantTranscriptParts, collectToolResultRun, messageContentText, stringifyJson } from "./rendering";
@@ -477,11 +478,7 @@ export class DeepSeekInbandScanner implements InbandScanner {
477
478
  #parseArgs(rawArgs: string): Record<string, unknown> {
478
479
  const trimmed = rawArgs.trim();
479
480
  if (trimmed.length === 0) return {};
480
- try {
481
- return asRecord(parseJsonWithRepair<unknown>(trimmed));
482
- } catch {
483
- return {};
484
- }
481
+ return asRecord(parseToolCallArguments(trimmed));
485
482
  }
486
483
 
487
484
  #skipWhitespace(): string {
@@ -1,5 +1,5 @@
1
- import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
2
1
  import type { Message, ToolCall } from "../types";
2
+ import { parseToolCallArguments } from "../utils/tool-call-arguments";
3
3
  import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
4
4
  import dialectPrompt from "./harmony.md" with { type: "text" };
5
5
  import {
@@ -225,11 +225,7 @@ export class HarmonyInbandScanner implements InbandScanner {
225
225
  #parseArgs(): Record<string, unknown> {
226
226
  const raw = this.#toolArgs.trim();
227
227
  if (raw.length === 0) return {};
228
- try {
229
- return asRecord(parseJsonWithRepair<unknown>(raw));
230
- } catch {
231
- return {};
232
- }
228
+ return asRecord(parseToolCallArguments(raw));
233
229
  }
234
230
 
235
231
  #clearBody(resetRawBlock = true): void {
@@ -1,5 +1,6 @@
1
1
  import { parseJsonWithRepair, parseStreamingJson } from "@oh-my-pi/pi-utils";
2
2
  import type { Message, ToolCall } from "../types";
3
+ import { parseToolCallArguments } from "../utils/tool-call-arguments";
3
4
  import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
4
5
  import dialectPrompt from "./hermes.md" with { type: "text" };
5
6
  import { renderChatMlTranscript, renderDelimitedThinking, renderToolResponseResults, stringifyJson } from "./rendering";
@@ -147,11 +148,7 @@ export class HermesInbandScanner implements InbandScanner {
147
148
  if (typeof parsed.name !== "string" || parsed.name.length === 0) return undefined;
148
149
  let args = parsed.arguments;
149
150
  if (typeof args === "string") {
150
- try {
151
- args = parseJsonWithRepair<unknown>(args);
152
- } catch {
153
- args = {};
154
- }
151
+ args = parseToolCallArguments(args);
155
152
  }
156
153
  return { name: parsed.name, arguments: asRecord(args) };
157
154
  } catch {
@@ -1,5 +1,5 @@
1
- import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
2
1
  import type { Message, ToolCall } from "../types";
2
+ import { parseToolCallArguments } from "../utils/tool-call-arguments";
3
3
  import { asRecord, normalizeKimiFunctionName, partialSuffixOverlapAny } from "./coercion";
4
4
  import dialectPrompt from "./kimi.md" with { type: "text" };
5
5
  import { assistantTranscriptParts, collectToolResultRun, messageContentText, stringifyJson } from "./rendering";
@@ -212,11 +212,7 @@ export class KimiInbandScanner implements InbandScanner {
212
212
 
213
213
  #parseArgs(rawArgs: string): Record<string, unknown> {
214
214
  if (rawArgs.length === 0) return {};
215
- try {
216
- return asRecord(parseJsonWithRepair<unknown>(rawArgs));
217
- } catch {
218
- return {};
219
- }
215
+ return asRecord(parseToolCallArguments(rawArgs));
220
216
  }
221
217
 
222
218
  #nextTokenIndex(): number {
@@ -13,6 +13,7 @@ import {
13
13
  setStreamingPartialJson,
14
14
  } from "../utils/block-symbols";
15
15
  import { AssistantMessageEventStream } from "../utils/event-stream";
16
+ import { INVALID_ARGUMENTS_RAW_LIMIT, invalidToolCallArguments } from "../utils/tool-call-arguments";
16
17
  import { buildStringArgsResolver } from "./coercion";
17
18
  import { createInbandScanner } from "./factory";
18
19
  import type { Dialect, InbandScanEvent, InbandScanner, InbandTool } from "./types";
@@ -180,6 +181,9 @@ class InbandStreamProjector {
180
181
  #fedLen = 0;
181
182
  #stopped = false;
182
183
  #responsePending = "";
184
+ // Bounded tail of the text actually fed to the scanner: the diagnostic for an
185
+ // unfinished envelope, which excludes anything cut off after a response token.
186
+ #scannerTail = "";
183
187
  // Provider-native tool calls forwarded live (e.g. Gemini still returns
184
188
  // `functionCall` parts under owned mode), keyed by the inner stream's
185
189
  // `contentIndex`. `#toolChannel` records which channel produced the turn's
@@ -291,7 +295,7 @@ class InbandStreamProjector {
291
295
  const responseIndex = firstTokenIndex(combined, this.#responseOpenTokens);
292
296
  if (responseIndex !== -1) {
293
297
  this.#responsePending = "";
294
- this.#apply(this.#scanner.feed(combined.slice(0, responseIndex)));
298
+ this.#apply(this.#feedScanner(combined.slice(0, responseIndex)));
295
299
  this.#stopped = true;
296
300
  return true;
297
301
  }
@@ -303,7 +307,7 @@ class InbandStreamProjector {
303
307
 
304
308
  const emitLength = combined.length - this.#responseOverlapLength;
305
309
  this.#responsePending = combined.slice(emitLength);
306
- this.#apply(this.#scanner.feed(combined.slice(0, emitLength)));
310
+ this.#apply(this.#feedScanner(combined.slice(0, emitLength)));
307
311
  return false;
308
312
  }
309
313
 
@@ -339,10 +343,26 @@ class InbandStreamProjector {
339
343
  for (const block of message.content) if (block.type === "text") fullText += block.text;
340
344
  if (!this.#stopped && fullText.length > this.#fedLen) this.text(fullText.slice(this.#fedLen));
341
345
  if (!this.#stopped && this.#responsePending.length > 0) {
342
- this.#apply(this.#scanner.feed(this.#responsePending));
346
+ this.#apply(this.#feedScanner(this.#responsePending));
343
347
  this.#responsePending = "";
344
348
  }
345
349
  this.#apply(this.#scanner.flush());
350
+ // Scanners discard incomplete envelopes, but their toolStart previews already
351
+ // live in the message. Refuse those calls instead of executing partial fields.
352
+ // The unfinished envelope ends the scanner-fed text, so the diagnostic keeps
353
+ // that tail rather than any prose before the call.
354
+ for (const entry of this.#toolBlocks.values()) {
355
+ entry.block.arguments = invalidToolCallArguments(this.#scannerTail, "Incomplete in-band tool call");
356
+ if (this.#emitEvents) {
357
+ this.#out.push({
358
+ type: "toolcall_end",
359
+ contentIndex: entry.index,
360
+ toolCall: entry.block,
361
+ partial: this.#partial,
362
+ });
363
+ }
364
+ }
365
+ this.#toolBlocks.clear();
346
366
  this.#closeText();
347
367
  this.#closeThinking();
348
368
  const hasTools = this.#partial.content.some(block => block.type === "toolCall");
@@ -353,6 +373,11 @@ class InbandStreamProjector {
353
373
  return finalMessage;
354
374
  }
355
375
 
376
+ #feedScanner(text: string): InbandScanEvent[] {
377
+ this.#scannerTail = (this.#scannerTail + text).slice(-INVALID_ARGUMENTS_RAW_LIMIT);
378
+ return this.#scanner.feed(text);
379
+ }
380
+
356
381
  #apply(events: InbandScanEvent[]): void {
357
382
  for (const event of events) {
358
383
  switch (event.type) {
@@ -1,5 +1,6 @@
1
1
  import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
2
2
  import type { Message, ToolCall } from "../types";
3
+ import { parseToolCallArguments } from "../utils/tool-call-arguments";
3
4
  import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
4
5
  import dialectPrompt from "./qwen3.md" with { type: "text" };
5
6
  import { renderChatMlTranscript, renderToolResponseResults, stringifyJson } from "./rendering";
@@ -180,11 +181,7 @@ export class Qwen3InbandScanner implements InbandScanner {
180
181
  if (typeof parsed.name !== "string" || parsed.name.length === 0) return undefined;
181
182
  let args = parsed.arguments;
182
183
  if (typeof args === "string") {
183
- try {
184
- args = parseJsonWithRepair<unknown>(args);
185
- } catch {
186
- args = {};
187
- }
184
+ args = parseToolCallArguments(args);
188
185
  }
189
186
  return { name: parsed.name, arguments: asRecord(args) };
190
187
  } catch {
@@ -37,6 +37,12 @@ const CREDITS_EXHAUSTED_PATTERN =
37
37
  // in unrelated diagnostics ("Failed to fetch usage credits from billing
38
38
  // service"), which must not rotate a healthy credential.
39
39
  const ANTHROPIC_CREDITS_REQUIRED_PATTERN = /\busage credits are required\b|\bcredits_required\b/i;
40
+ // Prepaid-balance exhaustion: Cursor ERROR_USAGE_PRICING_REQUIRED (code 44,
41
+ // surfaced as 429) "Your prepaid balance is used up: Add funds or enable auto
42
+ // top-up …". Account-local until topped up, so rotate to a sibling. The `\b`
43
+ // after the code keeps USAGE_PRICING_REQUIRED_CHANGEABLE out.
44
+ const PREPAID_BALANCE_EXHAUSTED_PATTERN =
45
+ /\busage_pricing_required\b|\bprepaid balance\b[^\n]{0,40}\b(?:used up|exhausted|depleted)\b/i;
40
46
  // Account billing ceilings: Anthropic "monthly spend limit" (#4787) and Google
41
47
  // "Your project has exceeded its monthly spending cap" (#13090). The `\b` after
42
48
  // `cap` keeps "spending capacity" — a throttle, not a billing ceiling — out.
@@ -260,6 +266,10 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason {
260
266
  return "QUOTA_EXHAUSTED";
261
267
  }
262
268
 
269
+ if (PREPAID_BALANCE_EXHAUSTED_PATTERN.test(errorMessage)) {
270
+ return "QUOTA_EXHAUSTED";
271
+ }
272
+
263
273
  if (
264
274
  lower.includes("per minute") ||
265
275
  lower.includes("rate limit") ||
@@ -434,6 +444,7 @@ export function matchesUsageLimitText(errorMessage: string): boolean {
434
444
  return (
435
445
  USAGE_LIMIT_PATTERN.test(errorMessage) ||
436
446
  ANTHROPIC_CREDITS_REQUIRED_PATTERN.test(errorMessage) ||
447
+ PREPAID_BALANCE_EXHAUSTED_PATTERN.test(errorMessage) ||
437
448
  CREDITS_EXHAUSTED_PATTERN.test(errorMessage) ||
438
449
  (CN_QUOTA_EXHAUSTED_PATTERN.test(errorMessage) && !CN_TRANSIENT_CAP_PATTERN.test(errorMessage)) ||
439
450
  SPEND_LIMIT_PATTERN.test(errorMessage) ||
@@ -13,16 +13,10 @@
13
13
  import type { Effort } from "@oh-my-pi/pi-catalog/effort";
14
14
  import { mapEffortToAnthropicAdaptiveEffort, requireSupportedEffort } from "@oh-my-pi/pi-catalog/model-thinking";
15
15
  import { calculateCost } from "@oh-my-pi/pi-catalog/models";
16
- import {
17
- $flag,
18
- fetchWithRetry,
19
- logger,
20
- parseStreamingJson,
21
- parseStreamingJsonThrottled,
22
- USER_AGENT,
23
- } from "@oh-my-pi/pi-utils";
16
+ import { $flag, fetchWithRetry, logger, parseStreamingJsonThrottled, USER_AGENT } from "@oh-my-pi/pi-utils";
24
17
  import { renderDemotedThinking } from "../dialect/demotion";
25
18
  import * as AIError from "../error";
19
+ import { parseToolCallArguments } from "../utils/tool-call-arguments";
26
20
  import { resolveAwsBearerToken } from "../registry/aws";
27
21
  import type {
28
22
  Api,
@@ -55,6 +49,7 @@ import { toolWireSchema } from "../utils/schema/wire";
55
49
  import { invalidateAwsCredentialCache, resolveAwsCredentials } from "./aws-credentials";
56
50
  import { decodeEventStream } from "./aws-eventstream";
57
51
  import { signRequest } from "./aws-sigv4";
52
+ import { isThinkingPrefixBindingError } from "./anthropic";
58
53
  import { parseAnthropicInputTransformations, THINKING_BINDING_CONTROLS_BETA } from "./anthropic-wire";
59
54
  import { isBedrockRequestMetadataValue } from "./bedrock-request-metadata";
60
55
  import { transformMessages } from "./transform-messages";
@@ -509,17 +504,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
509
504
  const urlPath = `${base.pathname.replace(/\/+$/, "")}/model/${encodeURIComponent(model.id)}/converse-stream`;
510
505
  const query = base.search.slice(1) || undefined;
511
506
  const url = `${base.origin}${urlPath}${base.search}`;
512
- rawRequestDump = {
513
- provider: model.provider,
514
- api: output.api,
515
- model: model.id,
516
- method: "POST",
517
- url,
518
- body: commandInput,
519
- };
520
507
 
521
- const bodyText = JSON.stringify(commandInput);
522
- const body = new TextEncoder().encode(bodyText);
523
508
  // Caller headers are merged BEFORE signing, so SigV4 covers them and they
524
509
  // reach the wire. Bedrock built its header map from scratch and ignored
525
510
  // `options.headers` entirely, so tracing/attribution headers set by a
@@ -559,37 +544,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
559
544
  "content-type": "application/json",
560
545
  accept: "application/vnd.amazon.eventstream",
561
546
  };
562
-
563
547
  const bearerToken = resolveBearerToken(options);
564
- let requestHeaders: Record<string, string>;
565
- if (bearerToken) {
566
- requestHeaders = { ...baseHeaders, Authorization: `Bearer ${bearerToken}` };
567
- } else {
568
- let credentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string };
569
- if ($flag("AWS_BEDROCK_SKIP_AUTH")) {
570
- credentials = { accessKeyId: "dummy-access-key", secretAccessKey: "dummy-secret-key" };
571
- } else {
572
- credentials = await resolveAwsCredentials({
573
- profile: options.profile,
574
- region,
575
- signal: options.signal,
576
- fetch: options.fetch,
577
- });
578
- }
579
- const signed = await signRequest({
580
- method: "POST",
581
- host,
582
- path: urlPath,
583
- query,
584
- body,
585
- region,
586
- service: "bedrock",
587
- credentials,
588
- headers: baseHeaders,
589
- });
590
- requestHeaders = { ...baseHeaders, ...signed };
591
- }
592
-
593
548
  // Bun's native fetch ceiling is disabled below (`timeout: false`) so
594
549
  // configurable watchdogs govern slow-prefill streams (issue #2422).
595
550
  // Direct callers that bypass `register-builtins` (which installs the
@@ -600,35 +555,92 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
600
555
  // Clear the pre-response timer the instant headers arrive (below): an
601
556
  // absolute `AbortSignal.timeout` would keep aborting the actively
602
557
  // streaming body, not just a stalled time-to-first-byte (issue #2422).
603
- const watchdog = armPreResponseTimeout(options.signal, firstEventTimeoutMs);
604
- let response: Response;
605
- try {
606
- response = await fetchWithRetry(url, {
558
+ const sendRequest = async (input: ConverseStreamRequest): Promise<Response> => {
559
+ const bodyText = JSON.stringify(input);
560
+ const body = new TextEncoder().encode(bodyText);
561
+ let requestHeaders: Record<string, string>;
562
+ if (bearerToken) {
563
+ requestHeaders = { ...baseHeaders, Authorization: `Bearer ${bearerToken}` };
564
+ } else {
565
+ let credentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string };
566
+ if ($flag("AWS_BEDROCK_SKIP_AUTH")) {
567
+ credentials = { accessKeyId: "dummy-access-key", secretAccessKey: "dummy-secret-key" };
568
+ } else {
569
+ credentials = await resolveAwsCredentials({
570
+ profile: options.profile,
571
+ region,
572
+ signal: options.signal,
573
+ fetch: options.fetch,
574
+ });
575
+ }
576
+ const signed = await signRequest({
577
+ method: "POST",
578
+ host,
579
+ path: urlPath,
580
+ query,
581
+ body,
582
+ region,
583
+ service: "bedrock",
584
+ credentials,
585
+ headers: baseHeaders,
586
+ });
587
+ requestHeaders = { ...baseHeaders, ...signed };
588
+ }
589
+ rawRequestDump = {
590
+ provider: model.provider,
591
+ api: output.api,
592
+ model: model.id,
607
593
  method: "POST",
608
- headers: requestHeaders,
609
- body,
610
- signal: watchdog.signal,
611
- fetch: options.fetch,
612
- timeout: false,
613
- });
614
- } finally {
615
- watchdog.clear();
616
- }
594
+ url,
595
+ body: input,
596
+ };
597
+ const watchdog = armPreResponseTimeout(options.signal, firstEventTimeoutMs);
598
+ try {
599
+ return await fetchWithRetry(url, {
600
+ method: "POST",
601
+ headers: requestHeaders,
602
+ body,
603
+ signal: watchdog.signal,
604
+ fetch: options.fetch,
605
+ timeout: false,
606
+ });
607
+ } finally {
608
+ watchdog.clear();
609
+ }
610
+ };
617
611
 
618
- if (!response.ok) {
612
+ let prefixBindingRetryAttempted = false;
613
+ let response: Response;
614
+ while (true) {
615
+ response = await sendRequest(commandInput);
616
+ if (response.ok) break;
619
617
  if (!bearerToken && (response.status === 401 || response.status === 403)) {
620
618
  // Stale cached credentials (e.g. rotated session keys in ~/.aws/credentials) —
621
619
  // drop the cache entry so the next attempt re-resolves from scratch.
622
620
  invalidateAwsCredentialCache({ profile: options.profile, region });
623
621
  }
624
622
  const errBody = await response.text().catch(() => "");
625
- throw new AIError.BedrockApiError(
626
- `Bedrock HTTP ${response.status}: ${errBody.slice(0, 1000)}`,
627
- response.status,
628
- {
629
- headers: response.headers,
630
- },
631
- );
623
+ const errorMessage = `Bedrock HTTP ${response.status}: ${errBody.slice(0, 1000)}`;
624
+ if (
625
+ !prefixBindingRetryAttempted &&
626
+ options.anthropicPrefixMismatchBehavior !== "error" &&
627
+ isThinkingPrefixBindingError(errorMessage)
628
+ ) {
629
+ prefixBindingRetryAttempted = true;
630
+ logger.warn("bedrock: thinking prefix changed, stripping bound thinking and retrying", {
631
+ provider: model.provider,
632
+ model: model.id,
633
+ baseUrl: base.origin,
634
+ });
635
+ commandInput = {
636
+ ...commandInput,
637
+ messages: dropBedrockReasoningContent(commandInput.messages),
638
+ };
639
+ continue;
640
+ }
641
+ throw new AIError.BedrockApiError(errorMessage, response.status, {
642
+ headers: response.headers,
643
+ });
632
644
  }
633
645
  if (!response.body) throw new AIError.BedrockApiError("Bedrock response has no body", response.status);
634
646
 
@@ -752,6 +764,12 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
752
764
  throw new AIError.BedrockApiError(output.errorMessage ?? "An unknown error occurred", 0);
753
765
  }
754
766
 
767
+ for (const [contentBlockIndex, index] of contentIndexByBlockIndex) {
768
+ const block = blocks[index];
769
+ if (block?.type === "toolCall" && block[kStreamingPartialJson] !== undefined) {
770
+ handleContentBlockStop({ contentBlockIndex }, blocks, contentIndexByBlockIndex, output, stream);
771
+ }
772
+ }
755
773
  output.duration = performance.now() - startTime;
756
774
  if (firstTokenTime) output.ttft = firstTokenTime - startTime;
757
775
  stream.push({ type: "done", reason: output.stopReason, message: output });
@@ -957,7 +975,7 @@ function handleContentBlockStop(
957
975
  stream.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: output });
958
976
  break;
959
977
  case "toolCall":
960
- block.arguments = parseStreamingJson(block[kStreamingPartialJson]);
978
+ block.arguments = parseToolCallArguments(block[kStreamingPartialJson]);
961
979
  clearStreamingPartialJson(block);
962
980
  stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: output });
963
981
  break;
@@ -1190,6 +1208,19 @@ function convertMessages(
1190
1208
  return result;
1191
1209
  }
1192
1210
 
1211
+ function dropBedrockReasoningContent(messages: WireMessage[]): WireMessage[] {
1212
+ const filtered: WireMessage[] = [];
1213
+ for (const message of messages) {
1214
+ if (message.role !== "assistant") {
1215
+ filtered.push(message);
1216
+ continue;
1217
+ }
1218
+ const content = message.content.filter(block => !("reasoningContent" in block));
1219
+ if (content.length > 0) filtered.push({ ...message, content });
1220
+ }
1221
+ return filtered;
1222
+ }
1223
+
1193
1224
  function messagesHaveToolBlocks(messages: WireMessage[]): boolean {
1194
1225
  for (const message of messages) {
1195
1226
  for (const block of message.content) {
@@ -18,6 +18,7 @@ import {
18
18
  import { NO_AUTH_SENTINEL } from "../auth-retry";
19
19
  import { renderDemotedThinking } from "../dialect/demotion";
20
20
  import * as AIError from "../error";
21
+ import { parseToolCallArguments } from "../utils/tool-call-arguments";
21
22
  import { getEnvApiKey, OUTPUT_FALLBACK_BUFFER } from "../stream";
22
23
  import type {
23
24
  AnthropicCompactionPayload,
@@ -2391,26 +2392,12 @@ const streamAnthropicOnce = (
2391
2392
  block[kStreamingPartialJson].length > 0
2392
2393
  ? block[kStreamingPartialJson]
2393
2394
  : JSON.stringify(block.arguments ?? {});
2394
- try {
2395
- block.arguments = parseJsonWithRepair(finalJson) as ToolCall["arguments"];
2396
- } catch (parseError) {
2397
- // Non-fatal: keep the best-effort arguments recovered by the throttled streaming
2398
- // parser instead of failing the turn on malformed/truncated tool-argument JSON.
2395
+ // Keep malformed calls non-fatal, but never execute their auto-closed previews.
2396
+ block.arguments = parseToolCallArguments(finalJson);
2397
+ if (isRecord(block.arguments) && "__parseError" in block.arguments) {
2399
2398
  reportAnthropicEnvelopeAnomaly(
2400
- `tool_use ${block.id} arguments are not valid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
2399
+ `tool_use ${block.id} arguments are not valid JSON: ${block.arguments.__parseError}`,
2401
2400
  );
2402
- const recoveredKeys = Object.keys(block.arguments ?? {});
2403
- if (recoveredKeys.length === 0) {
2404
- const maxLen = 512;
2405
- const truncatedJson =
2406
- finalJson.length <= maxLen
2407
- ? finalJson
2408
- : `${finalJson.slice(0, maxLen)}… [truncated ${finalJson.length - maxLen} chars]`;
2409
- block.arguments = {
2410
- __parseError: parseError instanceof Error ? parseError.message : String(parseError),
2411
- __rawJson: truncatedJson,
2412
- };
2413
- }
2414
2401
  }
2415
2402
  clearStreamingPartialJson(block);
2416
2403
  stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial: output });
@@ -13,6 +13,7 @@ import type { Effort } from "@oh-my-pi/pi-catalog/effort";
13
13
  import { appleFmAvailability, appleFmCancel, appleFmGenerate } from "@oh-my-pi/pi-natives";
14
14
  import { parseStreamingJson } from "@oh-my-pi/pi-utils";
15
15
  import * as AIError from "../error";
16
+ import { parseToolCallArguments } from "../utils/tool-call-arguments";
16
17
  import type {
17
18
  AssistantMessage,
18
19
  Context,
@@ -367,7 +368,7 @@ export const streamAppleFoundationModels: StreamFunction<"apple-foundation-model
367
368
  for (const index of toolIndices.values()) {
368
369
  const block = output.content[index] as ToolCallBlock;
369
370
  block.arguments = decodeFoundationModelsArguments(
370
- parseStreamingJson<Record<string, unknown>>(block[kStreamingPartialJson] ?? ""),
371
+ parseToolCallArguments(block[kStreamingPartialJson]),
371
372
  encodedPaths.get(block.name) ?? [],
372
373
  );
373
374
  clearStreamingPartialJson(block);