@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 +22 -0
- package/dist/types/utils/json-parse.d.ts +16 -5
- package/package.json +4 -5
- package/src/auth-gateway/server.ts +6 -4
- package/src/providers/cursor.ts +5 -8
- package/src/providers/openai-codex-responses.ts +66 -1
- package/src/providers/transform-messages.ts +247 -186
- package/src/rate-limit-utils.ts +8 -2
- package/src/utils/json-parse.ts +397 -21
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
|
-
*
|
|
5
|
-
*
|
|
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.
|
|
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.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.1.
|
|
43
|
-
"@oh-my-pi/pi-wire": "16.1.
|
|
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
|
|
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
|
}
|
package/src/providers/cursor.ts
CHANGED
|
@@ -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
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
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
|
|
168
|
-
messages
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
//
|
|
299
|
-
//
|
|
300
|
-
|
|
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
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}
|
|
354
|
+
if (block.type === "toolCall") {
|
|
355
|
+
const toolCall = block as ToolCall;
|
|
356
|
+
let normalizedToolCall: ToolCall = toolCall;
|
|
341
357
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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
|
|
354
|
-
}
|
|
382
|
+
return normalizedToolCall;
|
|
383
|
+
}
|
|
355
384
|
|
|
356
|
-
return
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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 =
|
|
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 =
|
|
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);
|
package/src/rate-limit-utils.ts
CHANGED
|
@@ -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 (
|
|
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);
|
package/src/utils/json-parse.ts
CHANGED
|
@@ -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
|
|
117
|
-
|
|
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
|
-
*
|
|
127
|
-
*
|
|
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
|
-
|
|
134
|
-
if (!
|
|
135
|
-
return {} as T;
|
|
136
|
-
}
|
|
513
|
+
const trimmed = partialJson?.trimStart();
|
|
514
|
+
if (!trimmed) return {} as T;
|
|
137
515
|
try {
|
|
138
|
-
return JSON.parse(
|
|
516
|
+
return JSON.parse(trimmed) as T;
|
|
139
517
|
} catch {
|
|
140
|
-
partialJson = repairJson(partialJson);
|
|
141
518
|
try {
|
|
142
|
-
return (
|
|
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
|
}
|