@oh-my-pi/pi-ai 16.1.9 → 16.1.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,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.10] - 2026-06-21
6
+
7
+ ### Changed
8
+
9
+ - Improved JSON robustness by replacing external dependency with a custom, high-performance parser
10
+ - Strengthened streaming JSON parsing to prevent non-finite numbers from surfacing as `undefined/NaN`
11
+ - Configured JSON parser to reject JS-specific `NaN` and `Infinity` values for tool arguments
12
+ - Replaced the JSON repair/parse helpers (`parseJsonWithRepair`, `parseStreamingJson`) with a single from-scratch tolerant parser (`RelaxedJson`) that accepts single-quoted strings, unquoted object keys, trailing/stray commas, `//` and `/* */` comments, Python `True`/`False`/`None`, raw control characters, invalid escapes, and unescaped apostrophes (`'it's'`). Final parsing still throws on truncated/garbage input (so a malformed tool call is skipped rather than executed with half-formed args) and rejects JS-only `NaN`/`Infinity`; streaming parsing stays non-throwing and rolls back incomplete trailing tokens instead of surfacing `undefined`/`NaN`. The Cursor provider's ad-hoc regex + JSON5 tool-argument parser now routes through the shared parser.
13
+
14
+ ### Fixed
15
+
16
+ - Fixed tool call ID normalization for Anthropic-compatible models
17
+ - Fixed Anthropic Messages replay sanitizing malformed tool-call IDs, including aborted native tool calls with empty IDs, so retries no longer send invalid `tool_use.id` / `tool_result.tool_use_id` pairs.
18
+ - Fixed the Codex Responses WebSocket transport attributing a prior turn's output to the current one on a reused connection: a trailing/duplicate frame from a cleanly-completed previous response that slipped past the queue drain could be consumed as this request's terminal (ending the turn with empty output) or as a stale tool call. Frames are now keyed by `response.id` — a frame carrying the previous response's id is dropped, and one carrying a third id (or a regressed `sequence_number`) fails closed so the turn retries instead of mixing two responses' streams. Idless frames (deltas, the rate-limit/metadata preamble, `response.created`-less streams) still pass through, matching upstream codex-rs.
19
+ - Fixed `transformMessages` pulling an earlier, orphaned tool result onto a later tool call that reused the same id (left behind when compaction folded the originating `tool_use` into a summary). The pending-call flush now pairs each call with a result positioned *after* its assistant turn, so a reused id surfaces its own output rather than a prior turn's.
20
+ - Fixed DashScope 429 rate-limit messages that mention authorization being classified as credential failures, preventing valid API keys from being invalidated after throttling. ([#3172](https://github.com/can1357/oh-my-pi/issues/3172))
21
+ - Fixed OpenCode Go `401 Insufficient balance` quota errors being treated as unknown failures instead of usage-limit errors, restoring credential rotation and fallback chains. ([#3169](https://github.com/can1357/oh-my-pi/issues/3169))
22
+
23
+ ### Removed
24
+
25
+ - Removed the `partial-json` dependency; streaming JSON parsing now uses the in-house `RelaxedJson` parser.
26
+
5
27
  ## [16.1.9] - 2026-06-21
6
28
 
7
29
  ### Added
@@ -1,11 +1,22 @@
1
+ /**
2
+ * Lightweight string-level repair of the escape/control-char hazards that make
3
+ * otherwise-valid JSON fail `JSON.parse`: raw control characters inside strings
4
+ * are escaped, and invalid `\x` escapes have their backslash escaped. Returns the
5
+ * input unchanged when no repair is needed. Pure string→string; does not parse.
6
+ */
1
7
  export declare function repairJson(json: string): string;
8
+ /**
9
+ * Final-parse a JSON value, repairing the common LLM malformations
10
+ * ({@link RelaxedJson}). Tries strict `JSON.parse` first (fast path, exact JSON
11
+ * semantics), then the relaxed parser. Throws when the input is unrepairable,
12
+ * truncated, or carries trailing garbage — so callers can skip a bad tool call
13
+ * rather than execute a half-formed one.
14
+ */
2
15
  export declare function parseJsonWithRepair<T>(json: string): T;
3
16
  /**
4
- * Attempts to parse potentially incomplete JSON during streaming.
5
- * Always returns a valid object, even if the JSON is incomplete.
6
- *
7
- * @param partialJson The partial JSON string from streaming
8
- * @returns Parsed object or empty object if parsing fails
17
+ * Parse possibly-incomplete JSON during streaming. Always returns a value, never
18
+ * throws: `{}` for empty/whitespace/unrecoverable buffers, and an auto-closed
19
+ * best-effort object for truncated ones.
9
20
  */
10
21
  export declare function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T;
11
22
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.1.9",
4
+ "version": "16.1.10",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,11 +38,10 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "@oh-my-pi/pi-catalog": "16.1.9",
42
- "@oh-my-pi/pi-utils": "16.1.9",
43
- "@oh-my-pi/pi-wire": "16.1.9",
41
+ "@oh-my-pi/pi-catalog": "16.1.10",
42
+ "@oh-my-pi/pi-utils": "16.1.10",
43
+ "@oh-my-pi/pi-wire": "16.1.10",
44
44
  "arktype": "^2.2.0",
45
- "partial-json": "^0.1.7",
46
45
  "zod": "^4"
47
46
  },
48
47
  "devDependencies": {
@@ -229,11 +229,10 @@ export function classifyGatewayError(err: unknown): { status: number; type: stri
229
229
  if (/\baborted\b|\babort signal\b/i.test(message)) {
230
230
  return { status: 499, type: "request_aborted", message };
231
231
  }
232
- if (/\b(?:unauthorized|forbidden)\b/i.test(message)) {
233
- return { status: 401, type: "authentication_error", message };
234
- }
235
232
  if (
236
- // Match rate-limit phrasings without colliding with
233
+ // Match rate-limit phrasings before auth wording: some providers
234
+ // describe throttling as "unauthorized due to rate limit".
235
+ // Keep boundaries so this does not collide with
237
236
  // `GenerateContentRequest`, `accelerate`, `iterate`, `deprecated`, etc.
238
237
  /\brate[- _]?limit(?:s|ed|ing)?\b|\bquota(?:_exceeded| exceeded)?\b|\btoo[- _]many[- _]requests\b/i.test(
239
238
  message,
@@ -249,6 +248,9 @@ export function classifyGatewayError(err: unknown): { status: number; type: stri
249
248
  ) {
250
249
  return { status: 429, type: "rate_limit_error", message };
251
250
  }
251
+ if (/\b(?:unauthorized|forbidden)\b/i.test(message)) {
252
+ return { status: 401, type: "authentication_error", message };
253
+ }
252
254
  if (/\b(?:unsupported|invalid_request|invalid request|bad request|malformed)\b/i.test(message)) {
253
255
  return { status: 400, type: "invalid_request_error", message };
254
256
  }
@@ -125,7 +125,7 @@ import type {
125
125
  } from "../types";
126
126
  import { normalizeSystemPrompts } from "../utils";
127
127
  import { AssistantMessageEventStream } from "../utils/event-stream";
128
- import { parseStreamingJson } from "../utils/json-parse";
128
+ import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse";
129
129
  import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug";
130
130
  import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
131
131
  import { toolWireSchema } from "../utils/schema/wire";
@@ -1781,13 +1781,10 @@ function parseToolArgsJson(text: string): unknown {
1781
1781
  return text;
1782
1782
  }
1783
1783
  try {
1784
- const normalized = trimmed
1785
- .replace(/\bNone\b/g, "null")
1786
- .replace(/\bTrue\b/g, "true")
1787
- .replace(/\bFalse\b/g, "false");
1788
- return Bun.JSON5.parse(normalized);
1789
- } catch {}
1790
- return text;
1784
+ return parseJsonWithRepair<unknown>(trimmed);
1785
+ } catch {
1786
+ return text;
1787
+ }
1791
1788
  }
1792
1789
 
1793
1790
  function decodeMcpArgValue(value: Uint8Array): unknown {
@@ -205,6 +205,17 @@ function isCodexStreamProgressEvent(event: unknown): boolean {
205
205
  return typeof type === "string" && CODEX_ADDITIONAL_PROGRESS_EVENT_TYPES.has(type);
206
206
  }
207
207
 
208
+ function extractCodexFrameResponseId(frame: Record<string, unknown>): string | undefined {
209
+ const response = (frame as { response?: { id?: unknown } }).response;
210
+ const id = response?.id;
211
+ return typeof id === "string" && id.length > 0 ? id : undefined;
212
+ }
213
+
214
+ function extractCodexFrameSequenceNumber(frame: Record<string, unknown>): number | undefined {
215
+ const raw = (frame as { sequence_number?: unknown }).sequence_number;
216
+ return typeof raw === "number" && Number.isFinite(raw) ? Math.trunc(raw) : undefined;
217
+ }
218
+
208
219
  type CodexWebSocketTimeoutDetails = {
209
220
  lastEventAt: number;
210
221
  lastEventType?: string;
@@ -2403,6 +2414,12 @@ class CodexWebSocketConnection {
2403
2414
  #lastInboundAt = 0;
2404
2415
  /** Wall-clock of the last heartbeat ping we issued; 0 if none yet. */
2405
2416
  #lastPingAt = 0;
2417
+ /**
2418
+ * Most recent `response.id` accepted on this socket, retained across
2419
+ * requests. Lets the next request drop a trailing/duplicate frame from the
2420
+ * previous (cleanly-completed) response that outlived the queue drain.
2421
+ */
2422
+ #lastSeenResponseId?: string;
2406
2423
 
2407
2424
  constructor(url: string, headers: Record<string, string>, options: CodexWebSocketConnectionOptions) {
2408
2425
  this.#url = url;
@@ -2650,6 +2667,11 @@ class CodexWebSocketConnection {
2650
2667
  let lastProgressEventType: string | undefined;
2651
2668
  let lastEventAt = lastProgressAt;
2652
2669
  let lastEventType: string | undefined;
2670
+ // Cross-request frame guard: lock onto this response's id and reject
2671
+ // frames belonging to another response interleaved on the reused socket.
2672
+ let activeResponseId: string | undefined;
2673
+ let lastSequence: number | undefined;
2674
+ const priorResponseId = this.#lastSeenResponseId;
2653
2675
  while (true) {
2654
2676
  let timeoutMs: number | undefined;
2655
2677
  let timeoutReason: string;
@@ -2691,8 +2713,51 @@ class CodexWebSocketConnection {
2691
2713
  if (next === null) {
2692
2714
  throw new CodexWebSocketTransportError(`websocket closed before response completion`);
2693
2715
  }
2694
- sawFirstEvent = true;
2695
2716
  const eventType = typeof next.type === "string" ? next.type : "";
2717
+ // Cross-request frame guard. The socket is reused across turns. Upstream
2718
+ // codex-rs leans on the protocol guarantee that nothing follows a
2719
+ // response's terminal event, but our queue can still surface a trailing
2720
+ // or duplicate frame from a cleanly-completed prior response after
2721
+ // #dropStaleFrames() drained the queue at send time. Attaching such a
2722
+ // frame to THIS turn misattributes an earlier turn's output (a stale
2723
+ // `response.completed` ends the turn early; a stale item makes the model
2724
+ // see an unrelated call). Only lifecycle events (created/completed/
2725
+ // failed/incomplete) carry a `response.id` — exactly the harmful ones —
2726
+ // so key the guard on it and let idless frames (deltas, the rate-limit/
2727
+ // metadata preamble, created-less streams) pass through, matching
2728
+ // upstream rather than gating on `response.created`.
2729
+ const frameResponseId = extractCodexFrameResponseId(next);
2730
+ const frameSequence = extractCodexFrameSequenceNumber(next);
2731
+ if (frameResponseId !== undefined) {
2732
+ if (activeResponseId === undefined) {
2733
+ if (priorResponseId !== undefined && frameResponseId === priorResponseId) {
2734
+ // Trailing/duplicate frame of the previous response that
2735
+ // outlived the drain. Drop without locking or advancing the
2736
+ // first-event clocks so our own response can still start.
2737
+ continue;
2738
+ }
2739
+ activeResponseId = frameResponseId;
2740
+ } else if (frameResponseId !== activeResponseId) {
2741
+ // A different response is interleaving on the socket; the idless
2742
+ // deltas that follow are indistinguishable, so fail closed
2743
+ // (retryable) instead of risking misattribution.
2744
+ this.close("stale-frame");
2745
+ throw new CodexWebSocketTransportError(
2746
+ `websocket frame for response ${frameResponseId} interleaved into active response ${activeResponseId}`,
2747
+ );
2748
+ }
2749
+ this.#lastSeenResponseId = frameResponseId;
2750
+ }
2751
+ if (frameSequence !== undefined) {
2752
+ if (activeResponseId !== undefined && lastSequence !== undefined && frameSequence < lastSequence) {
2753
+ this.close("stale-frame");
2754
+ throw new CodexWebSocketTransportError(
2755
+ `websocket sequence_number ${frameSequence} regressed below ${lastSequence} within response ${activeResponseId}`,
2756
+ );
2757
+ }
2758
+ lastSequence = frameSequence;
2759
+ }
2760
+ sawFirstEvent = true;
2696
2761
  lastEventAt = Date.now();
2697
2762
  lastEventType = eventType || undefined;
2698
2763
  if (isCodexStreamProgressEvent(next)) {
@@ -143,6 +143,29 @@ function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messa
143
143
  return model.api === "anthropic-messages";
144
144
  }
145
145
 
146
+ const ANTHROPIC_TOOL_CALL_ID_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
147
+
148
+ function isValidAnthropicToolCallId(id: string): boolean {
149
+ return ANTHROPIC_TOOL_CALL_ID_PATTERN.test(id);
150
+ }
151
+
152
+ function fallbackAnthropicToolCallId(originalId: string): string {
153
+ return `toolu_${Bun.hash(originalId).toString(36)}`;
154
+ }
155
+
156
+ function normalizeAnthropicTargetToolCallId<TApi extends Api>(
157
+ id: string,
158
+ model: Model<TApi>,
159
+ source: AssistantMessage,
160
+ normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string,
161
+ ): string {
162
+ if (isValidAnthropicToolCallId(id)) return id;
163
+ const normalized =
164
+ normalizeToolCallId?.(id, model, source) ?? id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, MAX_TOOL_CALL_ID_LENGTH);
165
+ if (isValidAnthropicToolCallId(normalized)) return normalized;
166
+ return fallbackAnthropicToolCallId(id);
167
+ }
168
+
146
169
  /**
147
170
  * Normalize tool call ID for cross-provider compatibility.
148
171
  * OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
@@ -164,211 +187,243 @@ export function transformMessages<TApi extends Api>(
164
187
 
165
188
  const latestSurvivingAssistantIndex = getLatestSurvivingAssistantIndex(messages);
166
189
  // First pass: transform messages (thinking blocks, tool call ID normalization)
167
- const transformed = deduplicateToolCallIds(
168
- messages.map((msg, index) => {
169
- // User and developer messages pass through unchanged
170
- if (msg.role === "user" || msg.role === "developer") {
171
- return msg;
172
- }
190
+ const normalizedMessages = messages.map((msg, index) => {
191
+ // User and developer messages pass through unchanged
192
+ if (msg.role === "user" || msg.role === "developer") {
193
+ return msg;
194
+ }
173
195
 
174
- // Handle toolResult messages - normalize toolCallId if we have a mapping
175
- if (msg.role === "toolResult") {
176
- const normalizedId = toolCallIdMap.get(msg.toolCallId);
177
- if (normalizedId && normalizedId !== msg.toolCallId) {
178
- return { ...msg, toolCallId: normalizedId };
179
- }
180
- return msg;
196
+ // Handle toolResult messages - normalize toolCallId if we have a mapping
197
+ if (msg.role === "toolResult") {
198
+ const normalizedId = toolCallIdMap.get(msg.toolCallId);
199
+ if (normalizedId && normalizedId !== msg.toolCallId) {
200
+ return { ...msg, toolCallId: normalizedId };
181
201
  }
202
+ return msg;
203
+ }
182
204
 
183
- // Assistant messages need transformation check
184
- if (msg.role === "assistant") {
185
- const assistantMsg = msg as AssistantMessage;
186
- const isSameModel =
187
- assistantMsg.provider === model.provider &&
188
- assistantMsg.api === model.api &&
189
- assistantMsg.model === model.id;
190
-
191
- const isAnthropicTarget = isAnthropicMessagesModel(model);
192
- // Anthropic's all-or-none contract on prior-turn thinking blocks
193
- // applies to every `anthropic-messages → anthropic-messages` replay,
194
- // not just the latest assistant turn. The legacy
195
- // `mustPreserveLatestAnthropicThinking` flag only honored it for the
196
- // latest turn; every prior turn fell through to the cross-API
197
- // text-demotion path whenever the conversation crossed a model id,
198
- // silently dropping the reasoning chain on continuation for custom
199
- // anthropic-messages providers configured via `models.yaml` and
200
- // session-level model swaps (#2257).
201
- const isAnthropicReplay = isAnthropicTarget && assistantMsg.api === "anthropic-messages";
202
- const isLatestSurvivingAssistant = index === latestSurvivingAssistantIndex;
203
- // Signature policy is a second axis. Anthropic cryptographically
204
- // binds reasoning signatures to its key+session+model, so cross-model
205
- // signatures must be stripped whenever official Anthropic is on
206
- // either end of the replay:
207
- // * official → 3p: the 3p target can't reverify the signature;
208
- // keeping it leaks private continuation metadata for no benefit.
209
- // * 3p → official: official rejects a foreign signature outright.
210
- // * official → official cross-model: the new model rejects the
211
- // previous model's signature.
212
- // 3p ↔ 3p replays preserve signatures because compatible providers
213
- // (Z.AI, DeepSeek, custom `models.yaml` providers) treat them as
214
- // opaque continuation hints rather than verified material; stripping
215
- // degrades the reasoning chain into unsigned/text on the next turn
216
- // (#2265). Source-side official detection uses the canonical catalog
217
- // provider id `"anthropic"` because assistant messages carry no
218
- // `baseUrl` — a user who manually points `provider: "anthropic"` at
219
- // a custom proxy via `models.yaml` will see signatures stripped, the
220
- // conservative direction (degraded reasoning, not broken requests).
221
- const isOfficialAnthropicSource = isAnthropicReplay && assistantMsg.provider === "anthropic";
222
- const isOfficialAnthropicTarget = isAnthropicTarget && model.compat.officialEndpoint;
223
- const officialAnthropicInvolved = isOfficialAnthropicSource || isOfficialAnthropicTarget;
224
- // Compatible Anthropic-messages reasoning targets that accept
225
- // unsigned thinking natively (Z.AI, DeepSeek, the generic
226
- // `reasoning && !official` case in the compat builder). Used to keep
227
- // `redacted_thinking` siblings beside unsigned visible thinking on
228
- // targets that won't text-demote it.
229
- const replaysUnsignedAnthropicThinking = isAnthropicTarget && model.compat.replayUnsignedThinking;
230
- // Thinking signatures can be untrustworthy for two distinct reasons with very
231
- // different blast radii:
232
- //
233
- // 1. Aborted/errored turns: the stream stopped mid-block, so only the block
234
- // that was streaming at the abort point — always the FINAL content block —
235
- // can carry a partially-streamed (invalid) signature. Every earlier block
236
- // completed: Anthropic delivers a block's signature at its
237
- // `content_block_stop`, which necessarily fired before the next block began,
238
- // so those signatures are whole and valid. Stripping them would needlessly
239
- // discard a replayable thinking chain — e.g. interrupting during the visible
240
- // text output after thinking already finished leaves a fully-signed thinking
241
- // block that must be kept, or Anthropic rejects the replay with HTTP 400
242
- // "Invalid `signature` in `thinking` block".
243
- //
244
- // 2. Abandoned tool-use turns: a turn that carries toolCall blocks but did NOT
245
- // request tool execution (stopReason !== "toolUse" — e.g. adaptive-thinking
246
- // Opus emitting tool calls and then ending on `end_turn`/`stop`). The agent
247
- // loop pairs those calls with placeholder tool_results to keep the
248
- // tool_use/tool_result contract valid. The turn completed cleanly, but its
249
- // signatures are end_turn-bound and cannot be replayed in that synthesized
250
- // continuation, so EVERY thinking signature is stripped.
251
- //
252
- // Latest abandoned turns are exempt because Anthropic requires thinking blocks
253
- // from its most recent response to remain byte-for-byte unmodified.
254
- const invalidStopReason = assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error";
255
- const abandonedToolUse =
256
- !invalidStopReason &&
257
- assistantMsg.stopReason !== "toolUse" &&
258
- assistantMsg.content.some(b => b.type === "toolCall");
259
- const lastBlockIndex = assistantMsg.content.length - 1;
260
-
261
- const transformedContent = assistantMsg.content.flatMap((block, blockIndex) => {
262
- if (block.type === "thinking") {
263
- // Only an aborted/errored turn's final (mid-stream) block can hold a
264
- // partial signature; abandoned tool-use turns strip all. Drop the
265
- // untrustworthy signature so the encoder can downgrade the block to text.
266
- const signatureUntrustworthy =
267
- abandonedToolUse || (invalidStopReason && blockIndex === lastBlockIndex);
268
- let sanitized: typeof block =
269
- signatureUntrustworthy && block.thinkingSignature
270
- ? { ...block, thinkingSignature: undefined }
271
- : block;
272
- if (isAnthropicReplay) {
273
- // Latest abandoned turn: Anthropic's byte-for-byte rule forbids
274
- // even stripping a signature on the latest message.
275
- if (isLatestSurvivingAssistant && abandonedToolUse) return block;
276
- // Cross-model prior turns crossing an official Anthropic endpoint
277
- // must strip the source signature so the downstream encoder
278
- // applies its `replayUnsignedThinking` policy (unsigned thinking
279
- // is emitted natively on Anthropic-compatible reasoning endpoints
280
- // and demoted to text on official Anthropic). 3p 3p replays
281
- // keep the signature so the reasoning chain stays signed on
282
- // continuation (#2265).
283
- if (
284
- !isLatestSurvivingAssistant &&
285
- !isSameModel &&
286
- officialAnthropicInvolved &&
287
- sanitized.thinkingSignature
288
- ) {
289
- sanitized = { ...sanitized, thinkingSignature: undefined };
290
- }
291
- // Drop blocks with neither a signature anchor nor any text —
292
- // nothing for the next turn to replay.
293
- if (!sanitized.thinkingSignature && (!sanitized.thinking || sanitized.thinking.trim() === "")) {
294
- return [];
295
- }
296
- return sanitized;
205
+ // Assistant messages need transformation check
206
+ if (msg.role === "assistant") {
207
+ const assistantMsg = msg as AssistantMessage;
208
+ const isSameModel =
209
+ assistantMsg.provider === model.provider &&
210
+ assistantMsg.api === model.api &&
211
+ assistantMsg.model === model.id;
212
+
213
+ const isAnthropicTarget = isAnthropicMessagesModel(model);
214
+ // Anthropic's all-or-none contract on prior-turn thinking blocks
215
+ // applies to every `anthropic-messages → anthropic-messages` replay,
216
+ // not just the latest assistant turn. The legacy
217
+ // `mustPreserveLatestAnthropicThinking` flag only honored it for the
218
+ // latest turn; every prior turn fell through to the cross-API
219
+ // text-demotion path whenever the conversation crossed a model id,
220
+ // silently dropping the reasoning chain on continuation for custom
221
+ // anthropic-messages providers configured via `models.yaml` and
222
+ // session-level model swaps (#2257).
223
+ const isAnthropicReplay = isAnthropicTarget && assistantMsg.api === "anthropic-messages";
224
+ const isLatestSurvivingAssistant = index === latestSurvivingAssistantIndex;
225
+ // Signature policy is a second axis. Anthropic cryptographically
226
+ // binds reasoning signatures to its key+session+model, so cross-model
227
+ // signatures must be stripped whenever official Anthropic is on
228
+ // either end of the replay:
229
+ // * official → 3p: the 3p target can't reverify the signature;
230
+ // keeping it leaks private continuation metadata for no benefit.
231
+ // * 3p → official: official rejects a foreign signature outright.
232
+ // * official → official cross-model: the new model rejects the
233
+ // previous model's signature.
234
+ // 3p ↔ 3p replays preserve signatures because compatible providers
235
+ // (Z.AI, DeepSeek, custom `models.yaml` providers) treat them as
236
+ // opaque continuation hints rather than verified material; stripping
237
+ // degrades the reasoning chain into unsigned/text on the next turn
238
+ // (#2265). Source-side official detection uses the canonical catalog
239
+ // provider id `"anthropic"` because assistant messages carry no
240
+ // `baseUrl` — a user who manually points `provider: "anthropic"` at
241
+ // a custom proxy via `models.yaml` will see signatures stripped, the
242
+ // conservative direction (degraded reasoning, not broken requests).
243
+ const isOfficialAnthropicSource = isAnthropicReplay && assistantMsg.provider === "anthropic";
244
+ const isOfficialAnthropicTarget = isAnthropicTarget && model.compat.officialEndpoint;
245
+ const officialAnthropicInvolved = isOfficialAnthropicSource || isOfficialAnthropicTarget;
246
+ // Compatible Anthropic-messages reasoning targets that accept
247
+ // unsigned thinking natively (Z.AI, DeepSeek, the generic
248
+ // `reasoning && !official` case in the compat builder). Used to keep
249
+ // `redacted_thinking` siblings beside unsigned visible thinking on
250
+ // targets that won't text-demote it.
251
+ const replaysUnsignedAnthropicThinking = isAnthropicTarget && model.compat.replayUnsignedThinking;
252
+ // Thinking signatures can be untrustworthy for two distinct reasons with very
253
+ // different blast radii:
254
+ //
255
+ // 1. Aborted/errored turns: the stream stopped mid-block, so only the block
256
+ // that was streaming at the abort point — always the FINAL content block —
257
+ // can carry a partially-streamed (invalid) signature. Every earlier block
258
+ // completed: Anthropic delivers a block's signature at its
259
+ // `content_block_stop`, which necessarily fired before the next block began,
260
+ // so those signatures are whole and valid. Stripping them would needlessly
261
+ // discard a replayable thinking chain — e.g. interrupting during the visible
262
+ // text output after thinking already finished leaves a fully-signed thinking
263
+ // block that must be kept, or Anthropic rejects the replay with HTTP 400
264
+ // "Invalid `signature` in `thinking` block".
265
+ //
266
+ // 2. Abandoned tool-use turns: a turn that carries toolCall blocks but did NOT
267
+ // request tool execution (stopReason !== "toolUse" — e.g. adaptive-thinking
268
+ // Opus emitting tool calls and then ending on `end_turn`/`stop`). The agent
269
+ // loop pairs those calls with placeholder tool_results to keep the
270
+ // tool_use/tool_result contract valid. The turn completed cleanly, but its
271
+ // signatures are end_turn-bound and cannot be replayed in that synthesized
272
+ // continuation, so EVERY thinking signature is stripped.
273
+ //
274
+ // Latest abandoned turns are exempt because Anthropic requires thinking blocks
275
+ // from its most recent response to remain byte-for-byte unmodified.
276
+ const invalidStopReason = assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error";
277
+ const abandonedToolUse =
278
+ !invalidStopReason &&
279
+ assistantMsg.stopReason !== "toolUse" &&
280
+ assistantMsg.content.some(b => b.type === "toolCall");
281
+ const lastBlockIndex = assistantMsg.content.length - 1;
282
+
283
+ const transformedContent = assistantMsg.content.flatMap((block, blockIndex) => {
284
+ if (block.type === "thinking") {
285
+ // Only an aborted/errored turn's final (mid-stream) block can hold a
286
+ // partial signature; abandoned tool-use turns strip all. Drop the
287
+ // untrustworthy signature so the encoder can downgrade the block to text.
288
+ const signatureUntrustworthy = abandonedToolUse || (invalidStopReason && blockIndex === lastBlockIndex);
289
+ let sanitized: typeof block =
290
+ signatureUntrustworthy && block.thinkingSignature
291
+ ? { ...block, thinkingSignature: undefined }
292
+ : block;
293
+ if (isAnthropicReplay) {
294
+ // Latest abandoned turn: Anthropic's byte-for-byte rule forbids
295
+ // even stripping a signature on the latest message.
296
+ if (isLatestSurvivingAssistant && abandonedToolUse) return block;
297
+ // Cross-model prior turns crossing an official Anthropic endpoint
298
+ // must strip the source signature so the downstream encoder
299
+ // applies its `replayUnsignedThinking` policy (unsigned thinking
300
+ // is emitted natively on Anthropic-compatible reasoning endpoints
301
+ // and demoted to text on official Anthropic). 3p ↔ 3p replays
302
+ // keep the signature so the reasoning chain stays signed on
303
+ // continuation (#2265).
304
+ if (
305
+ !isLatestSurvivingAssistant &&
306
+ !isSameModel &&
307
+ officialAnthropicInvolved &&
308
+ sanitized.thinkingSignature
309
+ ) {
310
+ sanitized = { ...sanitized, thinkingSignature: undefined };
297
311
  }
298
- // Cross-API target: keep the existing text-demotion fallback.
299
- // For same model: keep thinking blocks with signatures (needed for replay)
300
- // even if the thinking text is empty (OpenAI encrypted reasoning)
301
- if (isSameModel && sanitized.thinkingSignature) return sanitized;
302
- // Skip empty thinking blocks, convert others to plain text
303
- if (!sanitized.thinking || sanitized.thinking.trim() === "") return [];
304
- if (isSameModel) return sanitized;
305
- return {
306
- type: "text" as const,
307
- text: sanitized.thinking,
308
- };
309
- }
310
-
311
- if (block.type === "redactedThinking") {
312
- // Redacted thinking is native-only. Keep it for same-model
313
- // signed replay, the latest byte-for-byte Anthropic turn, or
314
- // compatible targets that will also emit sibling unsigned
315
- // thinking natively. Drop it when the visible thinking was
316
- // cross-model stripped and will be demoted to text.
317
- if (isAnthropicReplay) {
318
- if (isSameModel || isLatestSurvivingAssistant || replaysUnsignedAnthropicThinking) return block;
312
+ // Drop blocks with neither a signature anchor nor any text
313
+ // nothing for the next turn to replay.
314
+ if (!sanitized.thinkingSignature && (!sanitized.thinking || sanitized.thinking.trim() === "")) {
319
315
  return [];
320
316
  }
321
- if (isSameModel) return block;
322
- return [];
317
+ return sanitized;
323
318
  }
319
+ // Cross-API target: keep the existing text-demotion fallback.
320
+ // For same model: keep thinking blocks with signatures (needed for replay)
321
+ // even if the thinking text is empty (OpenAI encrypted reasoning)
322
+ if (isSameModel && sanitized.thinkingSignature) return sanitized;
323
+ // Skip empty thinking blocks, convert others to plain text
324
+ if (!sanitized.thinking || sanitized.thinking.trim() === "") return [];
325
+ if (isSameModel) return sanitized;
326
+ return {
327
+ type: "text" as const,
328
+ text: sanitized.thinking,
329
+ };
330
+ }
324
331
 
325
- if (block.type === "text") {
326
- if (isSameModel) return block;
327
- return {
328
- type: "text" as const,
329
- text: block.text,
330
- };
332
+ if (block.type === "redactedThinking") {
333
+ // Redacted thinking is native-only. Keep it for same-model
334
+ // signed replay, the latest byte-for-byte Anthropic turn, or
335
+ // compatible targets that will also emit sibling unsigned
336
+ // thinking natively. Drop it when the visible thinking was
337
+ // cross-model stripped and will be demoted to text.
338
+ if (isAnthropicReplay) {
339
+ if (isSameModel || isLatestSurvivingAssistant || replaysUnsignedAnthropicThinking) return block;
340
+ return [];
331
341
  }
342
+ if (isSameModel) return block;
343
+ return [];
344
+ }
332
345
 
333
- if (block.type === "toolCall") {
334
- const toolCall = block as ToolCall;
335
- let normalizedToolCall: ToolCall = toolCall;
346
+ if (block.type === "text") {
347
+ if (isSameModel) return block;
348
+ return {
349
+ type: "text" as const,
350
+ text: block.text,
351
+ };
352
+ }
336
353
 
337
- if (!isSameModel && toolCall.thoughtSignature) {
338
- normalizedToolCall = { ...toolCall };
339
- delete (normalizedToolCall as { thoughtSignature?: string }).thoughtSignature;
340
- }
354
+ if (block.type === "toolCall") {
355
+ const toolCall = block as ToolCall;
356
+ let normalizedToolCall: ToolCall = toolCall;
341
357
 
342
- if (!isSameModel && normalizeToolCallId) {
343
- const normalizedId = normalizeToolCallId(toolCall.id, model, assistantMsg);
344
- if (normalizedId !== toolCall.id) {
345
- toolCallIdMap.set(toolCall.id, normalizedId);
346
- normalizedToolCall = { ...normalizedToolCall, id: normalizedId };
347
- }
348
- }
358
+ if (!isSameModel && toolCall.thoughtSignature) {
359
+ normalizedToolCall = { ...toolCall };
360
+ delete (normalizedToolCall as { thoughtSignature?: string }).thoughtSignature;
361
+ }
349
362
 
350
- return normalizedToolCall;
363
+ if (isAnthropicTarget) {
364
+ const normalizedId = normalizeAnthropicTargetToolCallId(
365
+ toolCall.id,
366
+ model,
367
+ assistantMsg,
368
+ normalizeToolCallId,
369
+ );
370
+ if (normalizedId !== toolCall.id) {
371
+ toolCallIdMap.set(toolCall.id, normalizedId);
372
+ normalizedToolCall = { ...normalizedToolCall, id: normalizedId };
373
+ }
374
+ } else if (!isSameModel && normalizeToolCallId) {
375
+ const normalizedId = normalizeToolCallId(toolCall.id, model, assistantMsg);
376
+ if (normalizedId !== toolCall.id) {
377
+ toolCallIdMap.set(toolCall.id, normalizedId);
378
+ normalizedToolCall = { ...normalizedToolCall, id: normalizedId };
379
+ }
351
380
  }
352
381
 
353
- return block;
354
- });
382
+ return normalizedToolCall;
383
+ }
355
384
 
356
- return {
357
- ...assistantMsg,
358
- content: transformedContent,
359
- };
360
- }
361
- return msg;
362
- }),
385
+ return block;
386
+ });
387
+
388
+ return {
389
+ ...assistantMsg,
390
+ content: transformedContent,
391
+ };
392
+ }
393
+ return msg;
394
+ });
395
+ const transformed = deduplicateToolCallIds(
396
+ normalizedMessages,
363
397
  maxNormalizedToolCallIdLength,
364
398
  duplicateToolCallIdSuffixPrefix,
365
399
  );
366
- const realToolResultsById = new Map<string, ToolResultMessage>();
367
- for (const msg of transformed) {
368
- if (msg.role === "toolResult" && !realToolResultsById.has(msg.toolCallId)) {
369
- realToolResultsById.set(msg.toolCallId, msg);
400
+ // All real tool results, keyed by id, in document order. One id can map to
401
+ // more than one result: compaction can fold an assistant `tool_use` into a
402
+ // summary string while its `tool_result` survives, and a later turn may reuse
403
+ // the id. `takeRealToolResult` pulls the earliest unconsumed result positioned
404
+ // AFTER the call's assistant turn, so an orphaned earlier result is never
405
+ // pulled forward onto a later call (which would surface a prior turn's output).
406
+ type IndexedToolResult = { index: number; msg: ToolResultMessage; consumed: boolean };
407
+ const realToolResultsById = new Map<string, IndexedToolResult[]>();
408
+ for (let index = 0; index < transformed.length; index++) {
409
+ const msg = transformed[index];
410
+ if (msg.role === "toolResult") {
411
+ const entry: IndexedToolResult = { index, msg, consumed: false };
412
+ const entries = realToolResultsById.get(msg.toolCallId);
413
+ if (entries) entries.push(entry);
414
+ else realToolResultsById.set(msg.toolCallId, [entry]);
370
415
  }
371
416
  }
417
+ const takeRealToolResult = (id: string, afterIndex: number): ToolResultMessage | undefined => {
418
+ const entries = realToolResultsById.get(id);
419
+ if (!entries) return undefined;
420
+ for (const entry of entries) {
421
+ if (entry.consumed || entry.index <= afterIndex) continue;
422
+ entry.consumed = true;
423
+ return entry.msg;
424
+ }
425
+ return undefined;
426
+ };
372
427
 
373
428
  // Anthropic rejects `tool_result` blocks whose `tool_use_id` does not appear in a prior
374
429
  // `tool_use` block. After handoff/compaction folds an assistant turn into a summary
@@ -387,8 +442,12 @@ export function transformMessages<TApi extends Api>(
387
442
  // followed by exactly one corresponding tool result.
388
443
  const result: Message[] = [];
389
444
  let pendingToolCalls: ToolCall[] = [];
445
+ // Index of the assistant turn that declared `pendingToolCalls`; a pulled
446
+ // result must be positioned after it (see `takeRealToolResult`).
447
+ let pendingToolCallsStartIndex = -1;
390
448
  let pendingAbortedToolCalls = new Map<string, ToolCall>();
391
449
  let pendingAbortedTimestamp: number | undefined;
450
+ let pendingAbortedStartIndex = -1;
392
451
  // Track which tool calls already have an emitted result so delayed/duplicate
393
452
  // toolResult messages cannot create a second provider-visible result.
394
453
  const toolCallStatus = new Map<string, ToolCallStatus>();
@@ -397,7 +456,7 @@ export function transformMessages<TApi extends Api>(
397
456
  if (pendingToolCalls.length === 0) return;
398
457
  for (const tc of pendingToolCalls) {
399
458
  if (toolCallStatus.has(tc.id)) continue;
400
- const realToolResult = realToolResultsById.get(tc.id);
459
+ const realToolResult = takeRealToolResult(tc.id, pendingToolCallsStartIndex);
401
460
  if (realToolResult) {
402
461
  result.push(realToolResult);
403
462
  toolCallStatus.set(tc.id, ToolCallStatus.Resolved);
@@ -420,7 +479,7 @@ export function transformMessages<TApi extends Api>(
420
479
  if (pendingAbortedTimestamp === undefined) return;
421
480
  for (const tc of pendingAbortedToolCalls.values()) {
422
481
  if (toolCallStatus.has(tc.id)) continue;
423
- const realToolResult = realToolResultsById.get(tc.id);
482
+ const realToolResult = takeRealToolResult(tc.id, pendingAbortedStartIndex);
424
483
  if (realToolResult) {
425
484
  result.push(realToolResult);
426
485
  toolCallStatus.set(tc.id, ToolCallStatus.Resolved);
@@ -476,11 +535,13 @@ export function transformMessages<TApi extends Api>(
476
535
  result.push(msg);
477
536
  pendingAbortedToolCalls = new Map(toolCalls.map(toolCall => [toolCall.id, toolCall] as const));
478
537
  pendingAbortedTimestamp = assistantMsg.timestamp;
538
+ pendingAbortedStartIndex = i;
479
539
  continue;
480
540
  }
481
541
 
482
542
  if (toolCalls.length > 0) {
483
543
  pendingToolCalls = toolCalls;
544
+ pendingToolCallsStartIndex = i;
484
545
  }
485
546
 
486
547
  result.push(msg);
@@ -18,6 +18,7 @@ const SERVER_ERROR_BACKOFF_MS = 20 * 1000; // 20s
18
18
 
19
19
  const ACCOUNT_RATE_LIMIT_PATTERN =
20
20
  /\baccount(?:'s)?\b[^\n]{0,80}\brate.?limit\b|\brate.?limit\b[^\n]{0,80}\baccount\b/i;
21
+ const INSUFFICIENT_BALANCE_PATTERN = /insufficient.?balance/i;
21
22
 
22
23
  /**
23
24
  * Classify a rate-limit error message into a reason category.
@@ -62,7 +63,12 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason {
62
63
  return "RATE_LIMIT_EXCEEDED";
63
64
  }
64
65
 
65
- if (lower.includes("exhausted") || lower.includes("quota") || lower.includes("usage limit")) {
66
+ if (
67
+ lower.includes("exhausted") ||
68
+ lower.includes("quota") ||
69
+ lower.includes("usage limit") ||
70
+ INSUFFICIENT_BALANCE_PATTERN.test(errorMessage)
71
+ ) {
66
72
  return "QUOTA_EXHAUSTED";
67
73
  }
68
74
 
@@ -94,7 +100,7 @@ export function calculateRateLimitBackoffMs(reason: RateLimitReason): number {
94
100
 
95
101
  /** Detect usage/quota limit errors in error messages (persistent, requires credential switch). */
96
102
  const USAGE_LIMIT_PATTERN =
97
- /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?exceeded|quota.?reached|resource.?exhausted|exhausted your capacity|quota will reset/i;
103
+ /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?exceeded|quota.?reached|resource.?exhausted|exhausted your capacity|quota will reset|insufficient.?balance/i;
98
104
 
99
105
  export function isUsageLimitError(errorMessage: string): boolean {
100
106
  return USAGE_LIMIT_PATTERN.test(errorMessage) || ACCOUNT_RATE_LIMIT_PATTERN.test(errorMessage);
@@ -1,8 +1,7 @@
1
- import { parse as partialParse } from "partial-json";
2
-
3
1
  const QUOTE = 0x22;
4
2
  const BACKSLASH = 0x5c;
5
3
  const U = 0x75;
4
+ const SQUOTE = 0x27;
6
5
 
7
6
  // Valid chars after `\`: " \ / b f n r t u
8
7
  const VALID_ESCAPE_CHAR = new Uint8Array(128);
@@ -21,10 +20,48 @@ const CONTROL_ESCAPES: readonly string[] = (() => {
21
20
  return e;
22
21
  })();
23
22
 
23
+ const HEX4_RE = /^[0-9a-fA-F]{4}$/;
24
+
24
25
  function isHexDigit(cp: number): boolean {
25
26
  return (cp >= 0x30 && cp <= 0x39) || ((cp | 0x20) >= 0x61 && (cp | 0x20) <= 0x66);
26
27
  }
27
28
 
29
+ function isWhitespace(cp: number): boolean {
30
+ return cp === 0x20 || cp === 0x09 || cp === 0x0a || cp === 0x0d;
31
+ }
32
+
33
+ function isIdentChar(cp: number): boolean {
34
+ return (
35
+ (cp >= 0x30 && cp <= 0x39) ||
36
+ ((cp | 0x20) >= 0x61 && (cp | 0x20) <= 0x7a) ||
37
+ cp === 0x5f /* _ */ ||
38
+ cp === 0x24 /* $ */
39
+ );
40
+ }
41
+
42
+ /** Bareword literals: standard JSON plus Python `True`/`False`/`None`. */
43
+ const KEYWORDS: readonly (readonly [string, unknown])[] = [
44
+ ["true", true],
45
+ ["false", false],
46
+ ["null", null],
47
+ ["True", true],
48
+ ["False", false],
49
+ ["None", null],
50
+ ];
51
+
52
+ /**
53
+ * Sentinel returned by partial-mode value parsing when an atomic value
54
+ * (number / keyword) is incomplete at the streaming edge, so the enclosing
55
+ * object/array rolls back to the last valid prefix instead of committing junk.
56
+ */
57
+ const INCOMPLETE = Symbol("incomplete");
58
+
59
+ /**
60
+ * Lightweight string-level repair of the escape/control-char hazards that make
61
+ * otherwise-valid JSON fail `JSON.parse`: raw control characters inside strings
62
+ * are escaped, and invalid `\x` escapes have their backslash escaped. Returns the
63
+ * input unchanged when no repair is needed. Pure string→string; does not parse.
64
+ */
28
65
  export function repairJson(json: string): string {
29
66
  const len = json.length;
30
67
  const parts: string[] = [];
@@ -110,38 +147,377 @@ export function repairJson(json: string): string {
110
147
  return parts.join("");
111
148
  }
112
149
 
150
+ /**
151
+ * Recursive-descent parser for a forgiving superset of JSON. Beyond strict JSON
152
+ * it accepts, and normalizes, the malformations LLM tool-call bodies leak in
153
+ * practice:
154
+ *
155
+ * - single-quoted strings and unquoted object keys (JSON5);
156
+ * - trailing / stray commas, and `//` + block comments;
157
+ * - Python literals `True` / `False` / `None` and JS `NaN` / `Infinity`;
158
+ * - raw control characters and invalid `\x` escapes inside strings (kept literally);
159
+ * - unescaped quotes inside strings — a quote only closes a string when followed
160
+ * by a value terminator, recovering apostrophes such as `'it's'`.
161
+ *
162
+ * In `partial` mode an unterminated string/object/array (or a value cut off at
163
+ * end-of-input) is auto-closed with whatever was parsed so far — for streaming.
164
+ * In strict mode, end-of-input mid-value and trailing garbage both throw, so a
165
+ * final parse never silently accepts a half-formed tool call.
166
+ */
167
+ class RelaxedJson {
168
+ readonly #s: string;
169
+ readonly #n: number;
170
+ readonly #partial: boolean;
171
+ #i = 0;
172
+
173
+ constructor(source: string, partial: boolean) {
174
+ this.#s = source;
175
+ this.#n = source.length;
176
+ this.#partial = partial;
177
+ }
178
+
179
+ parse(): unknown {
180
+ this.#ws();
181
+ if (this.#i >= this.#n) {
182
+ if (this.#partial) return undefined;
183
+ throw new SyntaxError("Unexpected end of JSON input");
184
+ }
185
+ const value = this.#value();
186
+ if (value === INCOMPLETE) return undefined;
187
+ this.#ws();
188
+ if (!this.#partial && this.#i < this.#n) {
189
+ throw new SyntaxError(`Unexpected trailing characters at position ${this.#i}`);
190
+ }
191
+ return value;
192
+ }
193
+
194
+ #ws(): void {
195
+ const s = this.#s;
196
+ for (;;) {
197
+ while (this.#i < this.#n && isWhitespace(s.charCodeAt(this.#i))) this.#i++;
198
+ if (this.#i + 1 < this.#n && s.charCodeAt(this.#i) === 0x2f /* / */) {
199
+ const next = s.charCodeAt(this.#i + 1);
200
+ if (next === 0x2f /* / line comment */) {
201
+ this.#i += 2;
202
+ while (this.#i < this.#n && s.charCodeAt(this.#i) !== 0x0a) this.#i++;
203
+ continue;
204
+ }
205
+ if (next === 0x2a /* * block comment */) {
206
+ this.#i += 2;
207
+ while (
208
+ this.#i + 1 < this.#n &&
209
+ !(s.charCodeAt(this.#i) === 0x2a && s.charCodeAt(this.#i + 1) === 0x2f)
210
+ ) {
211
+ this.#i++;
212
+ }
213
+ this.#i = Math.min(this.#i + 2, this.#n);
214
+ continue;
215
+ }
216
+ }
217
+ break;
218
+ }
219
+ }
220
+
221
+ #value(): unknown {
222
+ const s = this.#s;
223
+ const c = s[this.#i];
224
+ if (c === "{") return this.#object();
225
+ if (c === "[") return this.#array();
226
+ if (c === '"' || c === "'") return this.#string(s.charCodeAt(this.#i));
227
+ const cc = s.charCodeAt(this.#i);
228
+ if (cc === 0x2d /* - */ || cc === 0x2b /* + */ || cc === 0x2e /* . */ || (cc >= 0x30 && cc <= 0x39)) {
229
+ // JS-only NaN / Infinity are deliberately not accepted: a tool must not
230
+ // execute with a non-finite numeric arg; they fall through #number's
231
+ // NaN guard (strict throw / partial rollback) like other bad tokens.
232
+ return this.#number();
233
+ }
234
+ return this.#keyword();
235
+ }
236
+
237
+ #object(): Record<string, unknown> {
238
+ this.#i++; // consume {
239
+ const out: Record<string, unknown> = {};
240
+ for (;;) {
241
+ this.#ws();
242
+ if (this.#i >= this.#n) {
243
+ if (this.#partial) return out;
244
+ throw new SyntaxError("Unterminated object");
245
+ }
246
+ const c = this.#s[this.#i];
247
+ if (c === "}") {
248
+ this.#i++;
249
+ return out;
250
+ }
251
+ if (c === ",") {
252
+ // Tolerate leading / doubled / trailing commas.
253
+ this.#i++;
254
+ continue;
255
+ }
256
+ const key = this.#key();
257
+ this.#ws();
258
+ if (this.#i < this.#n && this.#s[this.#i] === ":") {
259
+ this.#i++;
260
+ } else if (this.#partial) {
261
+ return out;
262
+ } else {
263
+ throw new SyntaxError("Expected ':' in object");
264
+ }
265
+ this.#ws();
266
+ if (this.#i >= this.#n) {
267
+ if (this.#partial) return out;
268
+ throw new SyntaxError("Expected value after ':'");
269
+ }
270
+ const value = this.#value();
271
+ if (value === INCOMPLETE) return out;
272
+ out[key] = value;
273
+ this.#ws();
274
+ const d = this.#i < this.#n ? this.#s[this.#i] : "";
275
+ if (d === ",") {
276
+ this.#i++;
277
+ continue;
278
+ }
279
+ if (d === "}") {
280
+ this.#i++;
281
+ return out;
282
+ }
283
+ if (this.#partial) return out;
284
+ throw new SyntaxError("Expected ',' or '}' in object");
285
+ }
286
+ }
287
+
288
+ #array(): unknown[] {
289
+ this.#i++; // consume [
290
+ const out: unknown[] = [];
291
+ for (;;) {
292
+ this.#ws();
293
+ if (this.#i >= this.#n) {
294
+ if (this.#partial) return out;
295
+ throw new SyntaxError("Unterminated array");
296
+ }
297
+ const c = this.#s[this.#i];
298
+ if (c === "]") {
299
+ this.#i++;
300
+ return out;
301
+ }
302
+ if (c === ",") {
303
+ this.#i++;
304
+ continue;
305
+ }
306
+ const value = this.#value();
307
+ if (value === INCOMPLETE) return out;
308
+ out.push(value);
309
+ this.#ws();
310
+ const d = this.#i < this.#n ? this.#s[this.#i] : "";
311
+ if (d === ",") {
312
+ this.#i++;
313
+ continue;
314
+ }
315
+ if (d === "]") {
316
+ this.#i++;
317
+ return out;
318
+ }
319
+ if (this.#partial) return out;
320
+ throw new SyntaxError("Expected ',' or ']' in array");
321
+ }
322
+ }
323
+
324
+ #key(): string {
325
+ const c = this.#s[this.#i];
326
+ if (c === '"' || c === "'") return this.#string(this.#s.charCodeAt(this.#i));
327
+ // Unquoted identifier key: read until a structural delimiter / whitespace.
328
+ const start = this.#i;
329
+ while (this.#i < this.#n) {
330
+ const ch = this.#s[this.#i];
331
+ if (ch === ":" || ch === "," || ch === "}" || isWhitespace(this.#s.charCodeAt(this.#i))) break;
332
+ this.#i++;
333
+ }
334
+ if (this.#i === start) {
335
+ if (this.#partial) return "";
336
+ throw new SyntaxError("Expected object key");
337
+ }
338
+ return this.#s.slice(start, this.#i);
339
+ }
340
+
341
+ #string(quote: number): string {
342
+ const s = this.#s;
343
+ const n = this.#n;
344
+ let i = this.#i + 1; // skip opening quote
345
+ let out = "";
346
+ let runStart = i;
347
+ while (i < n) {
348
+ const cc = s.charCodeAt(i);
349
+ if (cc !== BACKSLASH && cc !== quote) {
350
+ i++;
351
+ continue;
352
+ }
353
+ if (cc === quote) {
354
+ // Apostrophe / inner-quote recovery (a quote that isn't followed by a
355
+ // value terminator is literal) is safe for single quotes and in partial
356
+ // mode. For double quotes in strict mode, close on the first unescaped
357
+ // quote like standard JSON so malformed structure fails loudly instead
358
+ // of silently swallowing commas/colons into one string.
359
+ const lenient = quote === SQUOTE || this.#partial;
360
+ if (!lenient || this.#closesString(i + 1)) {
361
+ out += s.slice(runStart, i);
362
+ this.#i = i + 1;
363
+ return out;
364
+ }
365
+ // Unescaped inner quote (e.g. apostrophe in `'it's'`) — keep it literal.
366
+ i++;
367
+ continue;
368
+ }
369
+ // Backslash escape.
370
+ out += s.slice(runStart, i);
371
+ i++;
372
+ if (i >= n) {
373
+ out += "\\";
374
+ runStart = i;
375
+ break;
376
+ }
377
+ const esc = s.charCodeAt(i);
378
+ switch (esc) {
379
+ case QUOTE:
380
+ out += '"';
381
+ break;
382
+ case SQUOTE:
383
+ out += "'";
384
+ break;
385
+ case BACKSLASH:
386
+ out += "\\";
387
+ break;
388
+ case 0x2f:
389
+ out += "/";
390
+ break;
391
+ case 0x62:
392
+ out += "\b";
393
+ break;
394
+ case 0x66:
395
+ out += "\f";
396
+ break;
397
+ case 0x6e:
398
+ out += "\n";
399
+ break;
400
+ case 0x72:
401
+ out += "\r";
402
+ break;
403
+ case 0x74:
404
+ out += "\t";
405
+ break;
406
+ case U: {
407
+ const hex = s.slice(i + 1, i + 5);
408
+ if (HEX4_RE.test(hex)) {
409
+ out += String.fromCharCode(parseInt(hex, 16));
410
+ i += 4;
411
+ } else {
412
+ out += "\\u"; // invalid \u — keep literal
413
+ }
414
+ break;
415
+ }
416
+ default:
417
+ out += `\\${s[i]}`; // invalid escape — keep backslash literal
418
+ }
419
+ i++;
420
+ runStart = i;
421
+ }
422
+ out += s.slice(runStart, i);
423
+ if (this.#partial) {
424
+ this.#i = i;
425
+ return out;
426
+ }
427
+ throw new SyntaxError("Unterminated string");
428
+ }
429
+
430
+ /** A quote closes a string only when the next non-space char ends a value. */
431
+ #closesString(from: number): boolean {
432
+ const s = this.#s;
433
+ let k = from;
434
+ while (k < this.#n && isWhitespace(s.charCodeAt(k))) k++;
435
+ if (k >= this.#n) return true;
436
+ const c = s[k];
437
+ return c === "," || c === "}" || c === "]" || c === ":";
438
+ }
439
+
440
+ #number(): unknown {
441
+ const s = this.#s;
442
+ const start = this.#i;
443
+ while (this.#i < this.#n) {
444
+ const ch = s[this.#i];
445
+ if (
446
+ (ch >= "0" && ch <= "9") ||
447
+ ch === "-" ||
448
+ ch === "+" ||
449
+ ch === "." ||
450
+ ch === "e" ||
451
+ ch === "E" ||
452
+ ch === "x" ||
453
+ ch === "X" ||
454
+ (ch >= "a" && ch <= "f") ||
455
+ (ch >= "A" && ch <= "F")
456
+ ) {
457
+ this.#i++;
458
+ } else {
459
+ break;
460
+ }
461
+ }
462
+ const token = s.slice(start, this.#i);
463
+ const num = Number(token);
464
+ if (Number.isNaN(num)) {
465
+ if (this.#partial) return INCOMPLETE;
466
+ throw new SyntaxError(`Invalid number: ${token}`);
467
+ }
468
+ return num;
469
+ }
470
+
471
+ #keyword(): unknown {
472
+ const s = this.#s;
473
+ const i = this.#i;
474
+ for (const [word, value] of KEYWORDS) {
475
+ // Require a non-identifier boundary so `Truex` / `nullish` are not misread
476
+ // as the keyword followed by junk.
477
+ if (s.startsWith(word, i) && !isIdentChar(s.charCodeAt(i + word.length))) {
478
+ this.#i += word.length;
479
+ return value;
480
+ }
481
+ }
482
+ if (this.#partial) {
483
+ // Incomplete / unrecognized atomic token at the streaming edge — signal the
484
+ // caller to roll back to the last valid prefix instead of committing junk.
485
+ this.#i = this.#n;
486
+ return INCOMPLETE;
487
+ }
488
+ throw new SyntaxError(`Unexpected token at position ${this.#i}`);
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Final-parse a JSON value, repairing the common LLM malformations
494
+ * ({@link RelaxedJson}). Tries strict `JSON.parse` first (fast path, exact JSON
495
+ * semantics), then the relaxed parser. Throws when the input is unrepairable,
496
+ * truncated, or carries trailing garbage — so callers can skip a bad tool call
497
+ * rather than execute a half-formed one.
498
+ */
113
499
  export function parseJsonWithRepair<T>(json: string): T {
114
500
  try {
115
501
  return JSON.parse(json) as T;
116
- } catch (error) {
117
- const repairedJson = repairJson(json);
118
- if (repairedJson !== json) {
119
- return JSON.parse(repairedJson) as T;
120
- }
121
- throw error;
502
+ } catch {
503
+ return new RelaxedJson(json, false).parse() as T;
122
504
  }
123
505
  }
124
506
 
125
507
  /**
126
- * Attempts to parse potentially incomplete JSON during streaming.
127
- * Always returns a valid object, even if the JSON is incomplete.
128
- *
129
- * @param partialJson The partial JSON string from streaming
130
- * @returns Parsed object or empty object if parsing fails
508
+ * Parse possibly-incomplete JSON during streaming. Always returns a value, never
509
+ * throws: `{}` for empty/whitespace/unrecoverable buffers, and an auto-closed
510
+ * best-effort object for truncated ones.
131
511
  */
132
512
  export function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T {
133
- partialJson = partialJson?.trimStart();
134
- if (!partialJson) {
135
- return {} as T;
136
- }
513
+ const trimmed = partialJson?.trimStart();
514
+ if (!trimmed) return {} as T;
137
515
  try {
138
- return JSON.parse(partialJson) as T;
516
+ return JSON.parse(trimmed) as T;
139
517
  } catch {
140
- partialJson = repairJson(partialJson);
141
518
  try {
142
- return (partialParse(partialJson) ?? {}) as T;
519
+ return (new RelaxedJson(trimmed, true).parse() ?? {}) as T;
143
520
  } catch {
144
- // If all parsing fails, return empty object
145
521
  return {} as T;
146
522
  }
147
523
  }