opencode-anthropic-fix 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.mjs +210 -63
- package/lib/accounts.mjs +30 -0
- package/lib/betas.mjs +1 -1
- package/lib/debug-dump.mjs +87 -0
- package/lib/diagnose.mjs +8 -1
- package/lib/mimicry/adapter-input.mjs +177 -15
- package/lib/mimicry/headers.mjs +1 -1
- package/lib/mimicry/response-stream.mjs +816 -112
- package/lib/mimicry/system-prompt.mjs +20 -10
- package/lib/mimicry/wire-compat.mjs +132 -15
- package/lib/rolling-summarizer.mjs +20 -5
- package/lib/unicode-text.mjs +111 -0
- package/package.json +4 -2
|
@@ -102,6 +102,30 @@ export function getMidStreamAccountError(parsed) {
|
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Reverse-map tool names inside one SSE `data:` line's JSON payload.
|
|
107
|
+
* Returns `fallback` unchanged if the payload is not valid JSON or no
|
|
108
|
+
* `mcp_`-prefixed / CC-PascalCase tool name needed remapping. Shared by
|
|
109
|
+
* {@link stripMcpPrefixFromSSE} (whole-text, regex-driven) and the
|
|
110
|
+
* chunk-boundary-independent line processor in
|
|
111
|
+
* {@link createTransformedSSEStream}, so both paths rewrite identically.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} jsonStr - the JSON payload captured after `data:`
|
|
114
|
+
* @param {string} fallback - text to return when nothing was rewritten
|
|
115
|
+
* @returns {string}
|
|
116
|
+
*/
|
|
117
|
+
function rewriteSSEDataJson(jsonStr, fallback) {
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(jsonStr);
|
|
120
|
+
if (stripMcpPrefixFromParsedEvent(parsed)) {
|
|
121
|
+
return `data: ${JSON.stringify(parsed)}`;
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// Not valid JSON — pass through unchanged.
|
|
125
|
+
}
|
|
126
|
+
return fallback;
|
|
127
|
+
}
|
|
128
|
+
|
|
105
129
|
/**
|
|
106
130
|
* Strip `mcp_` prefix from tool_use `name` fields in SSE data lines.
|
|
107
131
|
* Only modifies `name` values inside content blocks with `"type": "tool_use"`.
|
|
@@ -111,17 +135,7 @@ export function getMidStreamAccountError(parsed) {
|
|
|
111
135
|
* @returns {string}
|
|
112
136
|
*/
|
|
113
137
|
export function stripMcpPrefixFromSSE(text) {
|
|
114
|
-
return text.replace(/^data:\s*(.+)$/gm, (
|
|
115
|
-
try {
|
|
116
|
-
const parsed = JSON.parse(jsonStr);
|
|
117
|
-
if (stripMcpPrefixFromParsedEvent(parsed)) {
|
|
118
|
-
return `data: ${JSON.stringify(parsed)}`;
|
|
119
|
-
}
|
|
120
|
-
} catch {
|
|
121
|
-
// Not valid JSON — pass through unchanged.
|
|
122
|
-
}
|
|
123
|
-
return _match;
|
|
124
|
-
});
|
|
138
|
+
return text.replace(/^data:\s*(.+)$/gm, (match, jsonStr) => rewriteSSEDataJson(jsonStr, match));
|
|
125
139
|
}
|
|
126
140
|
|
|
127
141
|
/**
|
|
@@ -236,14 +250,582 @@ export function resolveStreamIdleTimeoutMs(cfg) {
|
|
|
236
250
|
return 0;
|
|
237
251
|
}
|
|
238
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Mutable cross-call state for {@link splitTerminatedLines}. `pendingPieces`
|
|
255
|
+
* holds the fragments of the line currently being assembled (oldest first);
|
|
256
|
+
* they are joined into one string only when that line's terminator is
|
|
257
|
+
* actually found, so a single very long line costs one join over its whole
|
|
258
|
+
* length instead of a fresh copy on every chunk. `heldCR` records that the
|
|
259
|
+
* line's content so far ends right at an unresolved trailing CR (the CR
|
|
260
|
+
* itself is never stored in `pendingPieces` — it is not line content, it is
|
|
261
|
+
* either about to merge into a CRLF terminator or about to stand alone as
|
|
262
|
+
* one).
|
|
263
|
+
* @typedef {{pendingPieces: string[], heldCR: boolean}} LineSplitState
|
|
264
|
+
*/
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @returns {LineSplitState}
|
|
268
|
+
*/
|
|
269
|
+
function createLineSplitState() {
|
|
270
|
+
return { pendingPieces: [], heldCR: false };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Split newly decoded text into complete lines, terminated by LF, CR, or
|
|
275
|
+
* CRLF (SSE line endings per the WHATWG spec), carrying any unresolved tail
|
|
276
|
+
* forward in `state` instead of guessing at a boundary. A lone trailing CR
|
|
277
|
+
* is ambiguous — it may be the first half of a CRLF pair whose LF arrives in
|
|
278
|
+
* the next chunk — so it is held back (`state.heldCR`) until either the next
|
|
279
|
+
* chunk resolves it or `flush` forces a decision at end-of-stream. This
|
|
280
|
+
* makes line/event framing independent of how upstream bytes happen to be
|
|
281
|
+
* chunked (U6): the same boundary is found whether a CRLF arrives whole or
|
|
282
|
+
* split across two `pull()` calls.
|
|
283
|
+
*
|
|
284
|
+
* Only `chunk` (plus, when `state.heldCR` is set, the single held-back CR)
|
|
285
|
+
* is scanned on each call — never the full accumulated line — so cost is
|
|
286
|
+
* O(chunk length) per call and O(total input length) overall, regardless of
|
|
287
|
+
* how many chunks one line is split across (perf fix for the quadratic
|
|
288
|
+
* blowup a per-call `carry + chunk` re-scan produced on very long single
|
|
289
|
+
* lines split into many small chunks).
|
|
290
|
+
*
|
|
291
|
+
* Line terminators are always consumed and never re-emitted; callers that
|
|
292
|
+
* reconstruct output join lines with `"\n"`, which reserializes framing
|
|
293
|
+
* (permitted by the mimicry contract) without altering any line's content.
|
|
294
|
+
*
|
|
295
|
+
* @param {LineSplitState} state - cross-call state, mutated in place
|
|
296
|
+
* @param {string} chunk - newly decoded text (may be empty, e.g. while the
|
|
297
|
+
* UTF-8 decoder is buffering an incomplete multi-byte sequence)
|
|
298
|
+
* @param {boolean} flush - true at EOF: resolve a trailing partial line or lone CR
|
|
299
|
+
* @returns {string[]} completed lines found in this call
|
|
300
|
+
*/
|
|
301
|
+
function splitTerminatedLines(state, chunk, flush) {
|
|
302
|
+
const lines = [];
|
|
303
|
+
const n = chunk.length;
|
|
304
|
+
let bufStart = 0;
|
|
305
|
+
let i = 0;
|
|
306
|
+
|
|
307
|
+
if (state.heldCR) {
|
|
308
|
+
if (n > 0) {
|
|
309
|
+
state.heldCR = false;
|
|
310
|
+
if (chunk[0] === "\n") {
|
|
311
|
+
// CRLF completed by this chunk's leading LF: terminator consumes it.
|
|
312
|
+
lines.push(state.pendingPieces.join(""));
|
|
313
|
+
state.pendingPieces = [];
|
|
314
|
+
bufStart = 1;
|
|
315
|
+
i = 1;
|
|
316
|
+
} else {
|
|
317
|
+
// Followed by something other than LF: the held CR was a lone-CR
|
|
318
|
+
// terminator on its own; this chunk starts the next line.
|
|
319
|
+
lines.push(state.pendingPieces.join(""));
|
|
320
|
+
state.pendingPieces = [];
|
|
321
|
+
}
|
|
322
|
+
} else if (flush) {
|
|
323
|
+
// EOF with nothing left to disambiguate the held CR: it terminates.
|
|
324
|
+
state.heldCR = false;
|
|
325
|
+
lines.push(state.pendingPieces.join(""));
|
|
326
|
+
state.pendingPieces = [];
|
|
327
|
+
} else {
|
|
328
|
+
// No new bytes yet (decoder still buffering) — keep waiting.
|
|
329
|
+
return lines;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
while (i < n) {
|
|
334
|
+
const c = chunk[i];
|
|
335
|
+
if (c === "\n") {
|
|
336
|
+
state.pendingPieces.push(chunk.slice(bufStart, i));
|
|
337
|
+
lines.push(state.pendingPieces.join(""));
|
|
338
|
+
state.pendingPieces = [];
|
|
339
|
+
bufStart = i + 1;
|
|
340
|
+
i += 1;
|
|
341
|
+
} else if (c === "\r") {
|
|
342
|
+
if (i + 1 < n) {
|
|
343
|
+
const consumed = chunk[i + 1] === "\n" ? 2 : 1;
|
|
344
|
+
state.pendingPieces.push(chunk.slice(bufStart, i));
|
|
345
|
+
lines.push(state.pendingPieces.join(""));
|
|
346
|
+
state.pendingPieces = [];
|
|
347
|
+
bufStart = i + consumed;
|
|
348
|
+
i += consumed;
|
|
349
|
+
} else if (flush) {
|
|
350
|
+
state.pendingPieces.push(chunk.slice(bufStart, i));
|
|
351
|
+
lines.push(state.pendingPieces.join(""));
|
|
352
|
+
state.pendingPieces = [];
|
|
353
|
+
bufStart = i + 1;
|
|
354
|
+
i += 1;
|
|
355
|
+
} else {
|
|
356
|
+
// Lone CR at the very end of this chunk: could still turn into a
|
|
357
|
+
// CRLF pair once more bytes arrive. Hold it and wait.
|
|
358
|
+
state.pendingPieces.push(chunk.slice(bufStart, i));
|
|
359
|
+
state.heldCR = true;
|
|
360
|
+
bufStart = i + 1;
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
i += 1;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (bufStart < n) {
|
|
369
|
+
state.pendingPieces.push(chunk.slice(bufStart, n));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (flush && state.pendingPieces.length > 0) {
|
|
373
|
+
lines.push(state.pendingPieces.join(""));
|
|
374
|
+
state.pendingPieces = [];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return lines;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Whether a response's body is an SSE event stream, per its Content-Type
|
|
382
|
+
* header. Real Anthropic API streaming responses always send this header;
|
|
383
|
+
* a non-SSE body (a JSON error payload, or a non-UTF-8 proxy/gateway HTML
|
|
384
|
+
* error page) does not, and must not be held to the strict UTF-8 contract
|
|
385
|
+
* below (L4 fix) — a decode failure there is a property of a broken
|
|
386
|
+
* intermediary, not of the account, and must not be reported as one. As of
|
|
387
|
+
* F5, a non-SSE body is not decoded at all: it bypasses this module's whole
|
|
388
|
+
* decode/line/re-encode pipeline via {@link createPassthroughStream}.
|
|
389
|
+
* @param {Response} response
|
|
390
|
+
* @returns {boolean}
|
|
391
|
+
*/
|
|
392
|
+
function isSSEContentType(response) {
|
|
393
|
+
const contentType = response.headers.get("content-type") || "";
|
|
394
|
+
return contentType.toLowerCase().includes("text/event-stream");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The exact opencode 1.18.32 host-SDK version the L5 allowlists
|
|
399
|
+
* ({@link SDK_KNOWN_BLOCK_TYPES}, {@link SDK_KNOWN_DELTA_TYPES},
|
|
400
|
+
* {@link SDK_KNOWN_TOP_LEVEL_EVENT_TYPES}, {@link SDK_KNOWN_CITATION_TYPES})
|
|
401
|
+
* were extracted against. Re-extract all four allowlists — and move this
|
|
402
|
+
* constant to match — the next time opencode bumps its pinned
|
|
403
|
+
* `@ai-sdk/anthropic` version; {@link shouldApplyHostCompatShim}'s threshold
|
|
404
|
+
* is only correct for the version this describes.
|
|
405
|
+
*/
|
|
406
|
+
const HOST_SDK_BASELINE_VERSION = Object.freeze([3, 0, 111]);
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Matches the `ai-sdk/anthropic/<semver>` token the Vercel AI SDK sends in
|
|
410
|
+
* its own outgoing `User-Agent` header (and which opencode 1.18.32 sends
|
|
411
|
+
* unchanged, since it embeds that SDK unmodified — see the version-constant
|
|
412
|
+
* note in the doc above {@link SDK_KNOWN_BLOCK_TYPES}).
|
|
413
|
+
*
|
|
414
|
+
* QA fix L1: the negative lookbehind requires the match to start at the
|
|
415
|
+
* beginning of the string or right after a non-identifier character, so a
|
|
416
|
+
* *different* package whose name merely ends in `ai-sdk` — e.g.
|
|
417
|
+
* `xai-sdk/anthropic/9.0.0` — never matches `ai-sdk/anthropic/...` as a
|
|
418
|
+
* substring. Any of start-of-string, a space, `(`, `;`, or `,` (the
|
|
419
|
+
* separators real `User-Agent` strings use between tokens) satisfies it.
|
|
420
|
+
*/
|
|
421
|
+
const HOST_SDK_USER_AGENT_RE = /(?<![A-Za-z0-9_-])ai-sdk\/anthropic\/(\d+)\.(\d+)\.(\d+)/;
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Whether the L5 host-compatibility shim (F3/F4) should run for a request
|
|
425
|
+
* whose *incoming* `User-Agent` header has this value.
|
|
426
|
+
*
|
|
427
|
+
* The shim's allowlists describe exactly opencode 1.18.32's pinned
|
|
428
|
+
* `@ai-sdk/anthropic@3.0.111` — see the doc above {@link SDK_KNOWN_BLOCK_TYPES}
|
|
429
|
+
* for how they were extracted, byte-offset and all. Conservative default:
|
|
430
|
+
* apply the shim whenever the host SDK's version token is absent or
|
|
431
|
+
* unparseable, or when the parsed version is `<= 3.0.111` — only a
|
|
432
|
+
* *strictly newer* `ai-sdk/anthropic` version disables it, on the
|
|
433
|
+
* assumption a newer SDK ships its own fix for the zod schema / switch
|
|
434
|
+
* statements this shim works around. When opencode bumps its pinned SDK
|
|
435
|
+
* version, re-extract every allowlist above and move
|
|
436
|
+
* {@link HOST_SDK_BASELINE_VERSION} to match — this function's threshold
|
|
437
|
+
* must move with it, or it will silently stop protecting (or start
|
|
438
|
+
* needlessly gating) the new version.
|
|
439
|
+
*
|
|
440
|
+
* @param {string | null | undefined} userAgent - the incoming request's
|
|
441
|
+
* `User-Agent` header value (not the outgoing header this plugin sends to
|
|
442
|
+
* Anthropic — the host's own, as received by this plugin).
|
|
443
|
+
* @returns {boolean}
|
|
444
|
+
*/
|
|
445
|
+
export function shouldApplyHostCompatShim(userAgent) {
|
|
446
|
+
if (typeof userAgent !== "string") return true;
|
|
447
|
+
const match = userAgent.match(HOST_SDK_USER_AGENT_RE);
|
|
448
|
+
if (!match) return true;
|
|
449
|
+
const version = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
450
|
+
for (let i = 0; i < 3; i++) {
|
|
451
|
+
if (version[i] !== HOST_SDK_BASELINE_VERSION[i]) {
|
|
452
|
+
return version[i] < HOST_SDK_BASELINE_VERSION[i];
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return true; // exactly equal to the baseline
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Host-compatibility shim (L5): opencode 1.18.32 embeds `@ai-sdk/anthropic`
|
|
460
|
+
* 3.0.111 (its own version constant, `UQ="3.0.111"`, sits inline in the
|
|
461
|
+
* bundle, and that exact string is what the SDK sends as the
|
|
462
|
+
* `ai-sdk/anthropic/3.0.111` token in its own outgoing `User-Agent` header —
|
|
463
|
+
* see {@link shouldApplyHostCompatShim}).
|
|
464
|
+
*
|
|
465
|
+
* Every SSE event that SDK receives is first validated by a zod
|
|
466
|
+
* `discriminatedUnion("type", [...])` (bound to the bundle constant `WL`,
|
|
467
|
+
* consumed via `successfulResponseHandler:aG(WL)` in `doStream`), producing
|
|
468
|
+
* `{success:true, value}` on a matching event or `{success:false, error}`
|
|
469
|
+
* otherwise. A validation failure does **not** throw — `doStream`'s
|
|
470
|
+
* `transform` re-enqueues it as an SDK-level `{type:"error", error}` stream
|
|
471
|
+
* part (`if(!H.success){K.enqueue({type:"error",error:H.error});return}`) —
|
|
472
|
+
* but the outcome for the turn is the same either way: a stream part the
|
|
473
|
+
* host doesn't otherwise expect. Separately, once `H.success` is true (so
|
|
474
|
+
* `z = H.value` already passed that schema), the same `transform` also runs
|
|
475
|
+
* two *exhaustive* switch statements with no catch-all — any
|
|
476
|
+
* `content_block_start.content_block.type` outside its case list throws
|
|
477
|
+
* `` Unsupported content block type: ${R} ``, and any
|
|
478
|
+
* `content_block_delta.delta.type` outside its case list throws
|
|
479
|
+
* `` Unsupported delta type: ${Q} ``, either of which kills the whole
|
|
480
|
+
* ReadableStream reader. In practice the zod schema's own discriminatedUnions
|
|
481
|
+
* for `content_block.type` and `delta.type` list the exact same literals as
|
|
482
|
+
* these switches, so an unrecognized value is normally rejected at the zod
|
|
483
|
+
* step first and the throw is a dead-code fallback — but whichever mechanism
|
|
484
|
+
* fires, a single unrecognized block/delta (or top-level event, or
|
|
485
|
+
* `citations_delta` citation type — see {@link SDK_KNOWN_TOP_LEVEL_EVENT_TYPES}
|
|
486
|
+
* and {@link SDK_KNOWN_CITATION_TYPES}) still breaks the entire turn, which is
|
|
487
|
+
* what this shim prevents. `content_block.type === "fallback"` is the one
|
|
488
|
+
* literal the SDK special-cases before that switch (`if(R==="fallback")
|
|
489
|
+
* return`) — it is always safe and never registers per-index state.
|
|
490
|
+
*
|
|
491
|
+
* These sets are that switch's (and the matching zod discriminatedUnion's —
|
|
492
|
+
* both list the exact same literals) exact case list, extracted read-only by
|
|
493
|
+
* scanning `opencode.exe` as text for the two throw sites and the `WL`
|
|
494
|
+
* schema definition, and reading the switch/union bodies around them (see
|
|
495
|
+
* the task/PR notes for the exact byte offsets and quoted snippets) — not
|
|
496
|
+
* guessed from docs. A profile that asks for `thinking.display:"updates"`
|
|
497
|
+
* (beta `thinking-display-updates-2026-08-18`, the 2.1.280 profile) can make
|
|
498
|
+
* the real Anthropic API return newer block/delta types (e.g.
|
|
499
|
+
* `connector_text`, `tool_addition`, `tool_removal`) that this pinned SDK
|
|
500
|
+
* version has never heard of; the shim below keeps those from ever reaching
|
|
501
|
+
* the SDK's zod schema / switches.
|
|
502
|
+
*/
|
|
503
|
+
const SDK_KNOWN_BLOCK_TYPES = new Set([
|
|
504
|
+
"fallback", // short-circuited before the inner switch; always safe, never registered
|
|
505
|
+
"text",
|
|
506
|
+
"thinking",
|
|
507
|
+
"redacted_thinking",
|
|
508
|
+
"compaction",
|
|
509
|
+
"tool_use",
|
|
510
|
+
"server_tool_use",
|
|
511
|
+
"web_fetch_tool_result",
|
|
512
|
+
"web_search_tool_result",
|
|
513
|
+
"code_execution_tool_result",
|
|
514
|
+
"bash_code_execution_tool_result",
|
|
515
|
+
"text_editor_code_execution_tool_result",
|
|
516
|
+
"tool_search_tool_result",
|
|
517
|
+
"advisor_tool_result",
|
|
518
|
+
"mcp_tool_use",
|
|
519
|
+
"mcp_tool_result",
|
|
520
|
+
]);
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* @see SDK_KNOWN_BLOCK_TYPES — same extraction, `content_block_delta`'s
|
|
524
|
+
* switch/union. `citations_delta`'s own `citation` field is a further
|
|
525
|
+
* nested discriminatedUnion of exactly 3 literals — see
|
|
526
|
+
* {@link SDK_KNOWN_CITATION_TYPES}.
|
|
527
|
+
*/
|
|
528
|
+
const SDK_KNOWN_DELTA_TYPES = new Set([
|
|
529
|
+
"text_delta",
|
|
530
|
+
"thinking_delta",
|
|
531
|
+
"signature_delta",
|
|
532
|
+
"compaction_delta",
|
|
533
|
+
"input_json_delta",
|
|
534
|
+
"citations_delta",
|
|
535
|
+
]);
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* SDK-known top-level SSE event types (L5, F4d). The outer zod
|
|
539
|
+
* `discriminatedUnion("type", [...])` bound to `WL` in the bundle accepts
|
|
540
|
+
* exactly these 8 literals — quoted from the bundle (whitespace/aliasing
|
|
541
|
+
* elided): `WL=E(()=>j(G.discriminatedUnion("type",[G.object({type:
|
|
542
|
+
* G.literal("message_start"),message:{...}}), ...,
|
|
543
|
+
* G.object({type:G.literal("content_block_stop"),index:G.number()}),
|
|
544
|
+
* G.object({type:G.literal("error"),error:G.object({type:G.string(),
|
|
545
|
+
* message:G.string()})}), G.object({type:G.literal("message_delta"),
|
|
546
|
+
* delta:{...},usage:{...}}), G.object({type:G.literal("message_stop")}),
|
|
547
|
+
* G.object({type:G.literal("ping")})])))`. Any other top-level event `type`
|
|
548
|
+
* fails that union (see the doc above {@link SDK_KNOWN_BLOCK_TYPES}) and
|
|
549
|
+
* must be dropped whole — `event:`/`id:`/comment/`data:` lines all
|
|
550
|
+
* together — before it ever reaches the SDK.
|
|
551
|
+
*/
|
|
552
|
+
const SDK_KNOWN_TOP_LEVEL_EVENT_TYPES = new Set([
|
|
553
|
+
"message_start",
|
|
554
|
+
"content_block_start",
|
|
555
|
+
"content_block_delta",
|
|
556
|
+
"content_block_stop",
|
|
557
|
+
"error",
|
|
558
|
+
"message_delta",
|
|
559
|
+
"message_stop",
|
|
560
|
+
"ping",
|
|
561
|
+
]);
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* SDK-known `citations_delta.citation.type` literals (L5, F4d). Nested one
|
|
565
|
+
* level further inside `content_block_delta.delta`'s discriminatedUnion,
|
|
566
|
+
* `citations_delta`'s `citation` field is itself a
|
|
567
|
+
* `discriminatedUnion("type", [...])` of exactly these 3 literals — quoted
|
|
568
|
+
* from the bundle: `G.object({type:G.literal("citations_delta"),citation:
|
|
569
|
+
* G.discriminatedUnion("type",[G.object({type:
|
|
570
|
+
* G.literal("web_search_result_location"),cited_text:G.string(),url:
|
|
571
|
+
* G.string(),title:G.string(),encrypted_index:G.string()}),
|
|
572
|
+
* G.object({type:G.literal("page_location"),cited_text:G.string(),
|
|
573
|
+
* document_index:G.number(),document_title:G.string().nullable(),
|
|
574
|
+
* start_page_number:G.number(),end_page_number:G.number()}),
|
|
575
|
+
* G.object({type:G.literal("char_location"),cited_text:G.string(),
|
|
576
|
+
* document_index:G.number(),document_title:G.string().nullable(),
|
|
577
|
+
* start_char_index:G.number(),end_char_index:G.number()})])})`. A
|
|
578
|
+
* `citations_delta` carrying any other citation type fails that union just
|
|
579
|
+
* like an unrecognized top-level event or block/delta type, and is dropped
|
|
580
|
+
* the same way an unrecognized `delta.type` is — as a dropped delta, since
|
|
581
|
+
* the enclosing `content_block_delta` event is the unit of failure here.
|
|
582
|
+
*/
|
|
583
|
+
const SDK_KNOWN_CITATION_TYPES = new Set(["web_search_result_location", "page_location", "char_location"]);
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Cross-call state for {@link applyHostCompatShim}, scoped to one stream.
|
|
587
|
+
* @typedef {object} HostCompatShimState
|
|
588
|
+
* @property {Set<number>} fallbackIndexes - `content_block_start` indexes
|
|
589
|
+
* rewritten to `{type:"fallback"}`; their deltas must be dropped too.
|
|
590
|
+
* @property {number} blockTypeFallbacks - count of block-start rewrites.
|
|
591
|
+
* @property {number} deltasDropped - count of deltas dropped (fallback index,
|
|
592
|
+
* an unrecognized `delta.type`, or an unrecognized `citations_delta`
|
|
593
|
+
* citation type).
|
|
594
|
+
* @property {number} eventsDropped - count of whole SSE events dropped for
|
|
595
|
+
* an unrecognized top-level `type` (F4d).
|
|
596
|
+
*/
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* @returns {HostCompatShimState}
|
|
600
|
+
*/
|
|
601
|
+
function createHostCompatShimState() {
|
|
602
|
+
return { fallbackIndexes: new Set(), blockTypeFallbacks: 0, deltasDropped: 0, eventsDropped: 0 };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Apply the L5 host-compatibility shim to one already-JSON-parsed SSE event,
|
|
607
|
+
* mutating `parsed` in place for a rewrite. An unrecognized top-level `type`
|
|
608
|
+
* (F4d, {@link SDK_KNOWN_TOP_LEVEL_EVENT_TYPES}) is dropped whole before any
|
|
609
|
+
* type-specific check below runs. Every other recognized event type other
|
|
610
|
+
* than content_block_start/delta/stop (ping, message_start, message_delta,
|
|
611
|
+
* error, message_stop) is otherwise untouched — this only gates the event
|
|
612
|
+
* types the SDK's zod schema and block/delta switches can reject or throw
|
|
613
|
+
* on.
|
|
614
|
+
*
|
|
615
|
+
* `content_block_stop` is deliberately never rewritten or dropped: the SDK's
|
|
616
|
+
* own stop handler is `if(u[index]!=null){...}` with an unconditional
|
|
617
|
+
* `delete u[index]` after — an index that a fallback start never registered
|
|
618
|
+
* just skips the `if` body and no-ops, so the stop event is always safe to
|
|
619
|
+
* forward as-is (see the switch extraction above {@link SDK_KNOWN_BLOCK_TYPES}).
|
|
620
|
+
*
|
|
621
|
+
* @param {any} parsed
|
|
622
|
+
* @param {HostCompatShimState} state
|
|
623
|
+
* @returns {"keep" | "rewrite" | "drop"}
|
|
624
|
+
*/
|
|
625
|
+
function applyHostCompatShim(parsed, state) {
|
|
626
|
+
if (!parsed || typeof parsed !== "object") return "keep";
|
|
627
|
+
|
|
628
|
+
if (typeof parsed.type === "string" && !SDK_KNOWN_TOP_LEVEL_EVENT_TYPES.has(parsed.type)) {
|
|
629
|
+
state.eventsDropped++;
|
|
630
|
+
return "drop";
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (parsed.type === "content_block_start") {
|
|
634
|
+
const blockType = parsed.content_block?.type;
|
|
635
|
+
if (typeof blockType === "string" && !SDK_KNOWN_BLOCK_TYPES.has(blockType)) {
|
|
636
|
+
state.fallbackIndexes.add(parsed.index);
|
|
637
|
+
state.blockTypeFallbacks++;
|
|
638
|
+
parsed.content_block = { type: "fallback" };
|
|
639
|
+
return "rewrite";
|
|
640
|
+
}
|
|
641
|
+
return "keep";
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (parsed.type === "content_block_delta") {
|
|
645
|
+
if (state.fallbackIndexes.has(parsed.index)) {
|
|
646
|
+
state.deltasDropped++;
|
|
647
|
+
return "drop";
|
|
648
|
+
}
|
|
649
|
+
const deltaType = parsed.delta?.type;
|
|
650
|
+
if (typeof deltaType === "string" && !SDK_KNOWN_DELTA_TYPES.has(deltaType)) {
|
|
651
|
+
state.deltasDropped++;
|
|
652
|
+
return "drop";
|
|
653
|
+
}
|
|
654
|
+
if (deltaType === "citations_delta") {
|
|
655
|
+
const citationType = parsed.delta?.citation?.type;
|
|
656
|
+
if (typeof citationType === "string" && !SDK_KNOWN_CITATION_TYPES.has(citationType)) {
|
|
657
|
+
state.deltasDropped++;
|
|
658
|
+
return "drop";
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return "keep";
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (parsed.type === "content_block_stop") {
|
|
665
|
+
// Safe either way (see doc above) — just stop remembering the index.
|
|
666
|
+
state.fallbackIndexes.delete(parsed.index);
|
|
667
|
+
return "keep";
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return "keep";
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Read the next chunk from `reader`, applying an idle-timeout watchdog when
|
|
675
|
+
* `idleTimeoutMs > 0` (parity with Claude Code's stream-idle watchdog: no
|
|
676
|
+
* silent hang on a stalled/half-dead connection). Shared by the SSE pipeline
|
|
677
|
+
* below and by {@link createPassthroughStream} (F5) so both honor the exact
|
|
678
|
+
* same watchdog contract. On timeout (or any other read error under the
|
|
679
|
+
* race), the caller is responsible for canceling `reader` and surfacing the
|
|
680
|
+
* rejection via `controller.error` — this helper only resolves or rejects.
|
|
681
|
+
* @param {ReadableStreamDefaultReader<Uint8Array>} reader
|
|
682
|
+
* @param {number} idleTimeoutMs
|
|
683
|
+
* @returns {Promise<ReadableStreamReadResult<Uint8Array>>}
|
|
684
|
+
*/
|
|
685
|
+
async function readWithIdleTimeout(reader, idleTimeoutMs) {
|
|
686
|
+
if (idleTimeoutMs <= 0) return reader.read();
|
|
687
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
688
|
+
let idleTimer;
|
|
689
|
+
try {
|
|
690
|
+
return await Promise.race([
|
|
691
|
+
reader.read(),
|
|
692
|
+
new Promise((_resolve, reject) => {
|
|
693
|
+
idleTimer = setTimeout(
|
|
694
|
+
() => reject(new Error(`stream idle timeout: no bytes for ${idleTimeoutMs}ms`)),
|
|
695
|
+
idleTimeoutMs,
|
|
696
|
+
);
|
|
697
|
+
}),
|
|
698
|
+
]);
|
|
699
|
+
} finally {
|
|
700
|
+
clearTimeout(idleTimer);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* F5: byte-for-byte pass-through for a non-SSE response body. Each chunk
|
|
706
|
+
* read from `reader` is enqueued exactly as received — no decode, no
|
|
707
|
+
* re-encode, no line reframing, and no synthesized trailing newline — so
|
|
708
|
+
* concatenating the enqueued chunks reproduces the original bytes exactly.
|
|
709
|
+
* This intentionally skips everything the SSE pipeline does: usage
|
|
710
|
+
* extraction, account-error detection, mcp_ tool-name rewriting, the L5
|
|
711
|
+
* host-compat shim, and the SSE debug capture side-channel are all
|
|
712
|
+
* SSE-specific and never apply to a non-SSE body. Only the idle-timeout
|
|
713
|
+
* watchdog is still honored.
|
|
714
|
+
*
|
|
715
|
+
* QA fix L3: this stream's bytes are whatever `reader` hands back, which for
|
|
716
|
+
* a fetch()/undici response is *already decoded* (e.g. gzip has been
|
|
717
|
+
* transparently inflated) — so a `Content-Length`/`Content-Encoding` copied
|
|
718
|
+
* from the original upstream response would describe the original
|
|
719
|
+
* (possibly compressed) wire bytes, not what this stream actually emits.
|
|
720
|
+
* The caller (`transformResponse` in index.mjs) strips both headers rather
|
|
721
|
+
* than forward a value that no longer matches the body.
|
|
722
|
+
*
|
|
723
|
+
* QA fix L4: `cancel` propagates the consumer's cancellation to `reader` so
|
|
724
|
+
* the upstream connection is torn down instead of left dangling when the
|
|
725
|
+
* consumer walks away early.
|
|
726
|
+
* @param {ReadableStreamDefaultReader<Uint8Array>} reader
|
|
727
|
+
* @param {number} idleTimeoutMs
|
|
728
|
+
* @returns {ReadableStream}
|
|
729
|
+
*/
|
|
730
|
+
function createPassthroughStream(reader, idleTimeoutMs) {
|
|
731
|
+
return new ReadableStream({
|
|
732
|
+
async pull(controller) {
|
|
733
|
+
let readResult;
|
|
734
|
+
try {
|
|
735
|
+
readResult = await readWithIdleTimeout(reader, idleTimeoutMs);
|
|
736
|
+
} catch (err) {
|
|
737
|
+
// Idle timeout (or read error): cancel the upstream reader and surface a
|
|
738
|
+
// clear error so the consumer can retry instead of waiting on a dead
|
|
739
|
+
// connection. Mirrors the SSE pipeline's own watchdog handling below.
|
|
740
|
+
try {
|
|
741
|
+
await reader.cancel();
|
|
742
|
+
} catch {
|
|
743
|
+
// Reader may already be released; nothing to do.
|
|
744
|
+
}
|
|
745
|
+
controller.error(err instanceof Error ? err : new Error(String(err)));
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
const { done, value } = readResult;
|
|
749
|
+
if (done) {
|
|
750
|
+
controller.close();
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
controller.enqueue(value);
|
|
754
|
+
},
|
|
755
|
+
cancel(reason) {
|
|
756
|
+
// L4: propagate the consumer's cancellation to the upstream reader.
|
|
757
|
+
return reader.cancel(reason);
|
|
758
|
+
},
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
239
762
|
/**
|
|
240
763
|
* Create a response body stream that rewrites tool names, extracts usage, and
|
|
241
764
|
* detects account-specific errors while preserving all other SSE bytes.
|
|
242
765
|
*
|
|
766
|
+
* UTF-8 contract (U5): for an SSE response (`Content-Type: text/event-stream`,
|
|
767
|
+
* per {@link isSSEContentType}) decoding is strict (`fatal: true`) and the
|
|
768
|
+
* decoder is flushed with no pending arguments at EOF. `{ stream: true }`
|
|
769
|
+
* already buffers a multi-byte sequence split across chunk boundaries and
|
|
770
|
+
* completes it normally once the rest arrives, so `fatal` changes nothing for
|
|
771
|
+
* a well-formed stream regardless of chunking — it only turns a byte sequence
|
|
772
|
+
* that is genuinely invalid (or still incomplete at the final flush) into an
|
|
773
|
+
* explicit thrown error instead of a silent U+FFFD substitution. That
|
|
774
|
+
* error is surfaced via `controller.error` (never a fabricated success, never
|
|
775
|
+
* a replay, never an account-specific callback) so a real U+FFFD present in
|
|
776
|
+
* valid input is never confused with decoder-inserted replacement text.
|
|
777
|
+
*
|
|
778
|
+
* A non-SSE response (any other Content-Type — e.g. a 4xx/5xx JSON error
|
|
779
|
+
* body, or an HTML error page from a proxy/gateway that isn't even UTF-8)
|
|
780
|
+
* skips this whole pipeline (F5): see {@link createPassthroughStream}. It is
|
|
781
|
+
* never decoded or re-encoded at all, so it cannot be held to any UTF-8
|
|
782
|
+
* contract, strict or lenient. (QA fix L3: the caller no longer forwards the
|
|
783
|
+
* original Content-Length/Content-Encoding for either path — see
|
|
784
|
+
* {@link createPassthroughStream}'s own doc.)
|
|
785
|
+
*
|
|
786
|
+
* SSE framing (U6): line and event-block boundaries are resolved by
|
|
787
|
+
* {@link splitTerminatedLines} instead of a per-chunk regex, so a CR/LF pair
|
|
788
|
+
* (or any line) split across chunk boundaries is parsed identically to one
|
|
789
|
+
* delivered whole — tool-name rewriting and usage/account-error extraction
|
|
790
|
+
* read from the very same lines, so they can never diverge.
|
|
791
|
+
*
|
|
792
|
+
* Host compatibility (L5): every event block is buffered and its `data:`
|
|
793
|
+
* payload parsed once (not just when usage/account-error tracking is
|
|
794
|
+
* requested — parsing itself must happen unconditionally, since usage and
|
|
795
|
+
* account-error extraction need it too), and that same parse feeds
|
|
796
|
+
* {@link applyHostCompatShim} whenever `options.hostCompatShim` is not
|
|
797
|
+
* `false` (see that option's own doc below). A block whose fate is "drop"
|
|
798
|
+
* has *all* of its lines (any `event:`, `id:`, comment, and `data:` lines)
|
|
799
|
+
* omitted from the output — never just the `data:` line — so the host's SSE
|
|
800
|
+
* reader never sees a named event with no matching data. Every other block
|
|
801
|
+
* is re-serialized byte-identically except for the specific `data:` line a
|
|
802
|
+
* rewrite touched.
|
|
803
|
+
*
|
|
243
804
|
* @param {Response} response
|
|
244
805
|
* @param {object} options
|
|
245
806
|
* @param {((stats: UsageStats) => void) | null} [options.onUsage]
|
|
246
807
|
* @param {((details: {reason: import('../backoff.mjs').RateLimitReason, invalidateToken: boolean}) => void) | null} [options.onAccountError]
|
|
808
|
+
* @param {((stats: {blockTypeFallbacks: number, deltasDropped: number, eventsDropped: number, dryRun?: boolean}) => void) | null} [options.onHostCompatShim]
|
|
809
|
+
* Called once at stream end, only if the L5 shim actually rewrote or
|
|
810
|
+
* dropped at least one event — mirrors {@link options.onUsage}'s
|
|
811
|
+
* "only fire if something happened" shape. When `hostCompatShim` is
|
|
812
|
+
* `false`, the shim itself never touches the real event or output, but
|
|
813
|
+
* (QA fix L1) it still runs as a DRY RUN against a copy of each parsed
|
|
814
|
+
* event, purely to report what it *would* have rewritten or dropped; if
|
|
815
|
+
* that dry run finds anything, this fires once with `dryRun: true` set
|
|
816
|
+
* (omitted/falsy on a normal, live-shim call) so callers such as
|
|
817
|
+
* index.mjs's debugLog can surface "a newer host SDK is receiving types
|
|
818
|
+
* the pinned allowlist doesn't know" instead of staying silent. Never
|
|
819
|
+
* called for a non-SSE body.
|
|
820
|
+
* @param {boolean} [options.hostCompatShim] - F3/F4: enable/disable the L5
|
|
821
|
+
* host-compat shim ({@link applyHostCompatShim}) for this stream. Defaults
|
|
822
|
+
* to `true`. Callers resolve this once per request from the *incoming*
|
|
823
|
+
* request's `User-Agent` via {@link shouldApplyHostCompatShim} — this
|
|
824
|
+
* function itself never inspects any header, it only obeys the resolved
|
|
825
|
+
* flag. `false` means no shim *rewriting*: unknown block/delta/event types
|
|
826
|
+
* and unknown citation types all pass through byte-identical — but see
|
|
827
|
+
* `onHostCompatShim` above for the dry-run report this still produces
|
|
828
|
+
* (QA fix L1).
|
|
247
829
|
* @param {string} [options.correlationId]
|
|
248
830
|
* @param {number} options.idleTimeoutMs
|
|
249
831
|
* @param {boolean} options.captureEnabled
|
|
@@ -252,10 +834,29 @@ export function resolveStreamIdleTimeoutMs(cfg) {
|
|
|
252
834
|
*/
|
|
253
835
|
export function createTransformedSSEStream(
|
|
254
836
|
response,
|
|
255
|
-
{
|
|
837
|
+
{
|
|
838
|
+
onUsage,
|
|
839
|
+
onAccountError,
|
|
840
|
+
onHostCompatShim,
|
|
841
|
+
hostCompatShim = true,
|
|
842
|
+
correlationId,
|
|
843
|
+
idleTimeoutMs,
|
|
844
|
+
captureEnabled,
|
|
845
|
+
writeSseCapture,
|
|
846
|
+
},
|
|
256
847
|
) {
|
|
257
848
|
const reader = response.body.getReader();
|
|
258
|
-
|
|
849
|
+
|
|
850
|
+
// F5: a non-SSE body (any Content-Type other than text/event-stream) is
|
|
851
|
+
// never framed as SSE — pass it through exactly as read instead of forcing
|
|
852
|
+
// it through the decode/line/re-encode pipeline below (see the UTF-8
|
|
853
|
+
// contract note above and createPassthroughStream's own doc).
|
|
854
|
+
if (!isSSEContentType(response)) {
|
|
855
|
+
return createPassthroughStream(reader, idleTimeoutMs);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Strict only for SSE — see the UTF-8 contract note above the function doc.
|
|
859
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
259
860
|
const encoder = new TextEncoder();
|
|
260
861
|
const EMPTY_CHUNK = new Uint8Array();
|
|
261
862
|
const MAX_SSE_CAPTURE_BYTES = 256 * 1024;
|
|
@@ -264,119 +865,196 @@ export function createTransformedSSEStream(
|
|
|
264
865
|
|
|
265
866
|
/** @type {UsageStats} */
|
|
266
867
|
const stats = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
|
267
|
-
|
|
268
|
-
|
|
868
|
+
/** Unresolved-line state for splitTerminatedLines, carried across pull() calls. */
|
|
869
|
+
const lineSplitState = createLineSplitState();
|
|
870
|
+
/** Cross-call state for the L5 host-compat shim, scoped to this one stream. */
|
|
871
|
+
const hostCompatShimState = createHostCompatShimState();
|
|
872
|
+
/**
|
|
873
|
+
* QA fix L1: cross-call state for a DRY RUN of the L5 shim, used only when
|
|
874
|
+
* `hostCompatShim` is `false` (a newer host SDK the allowlists above have
|
|
875
|
+
* not been re-extracted for). Tracked separately from `hostCompatShimState`
|
|
876
|
+
* because it is fed by a throwaway copy of each parsed event, never the
|
|
877
|
+
* real one — the dry run must never influence the real block/delta fate.
|
|
878
|
+
*/
|
|
879
|
+
const dryRunHostCompatShimState = createHostCompatShimState();
|
|
880
|
+
/**
|
|
881
|
+
* Raw (pre-rewrite) lines of the SSE event block currently being assembled
|
|
882
|
+
* (terminated by a blank line). Always populated: the L5 host-compat shim
|
|
883
|
+
* must see a whole block — event/id lines included — to drop one safely,
|
|
884
|
+
* not just when usage/account-error tracking is requested (that
|
|
885
|
+
* conditional gate was fine when block-buffering was only a usage/
|
|
886
|
+
* account-error optimization; L5 needs it unconditionally for correctness).
|
|
887
|
+
* @type {string[]}
|
|
888
|
+
*/
|
|
889
|
+
let currentBlockLines = [];
|
|
269
890
|
let accountErrorHandled = false;
|
|
270
891
|
|
|
271
892
|
/**
|
|
272
|
-
*
|
|
273
|
-
*
|
|
893
|
+
* Finalize the currently buffered event block and return the lines that
|
|
894
|
+
* should reach the output (in order, no trailing newline — the caller
|
|
895
|
+
* joins with "\n" and appends the block-terminating blank line itself).
|
|
896
|
+
* Parses the block's combined `data:` payload once and feeds that single
|
|
897
|
+
* parse to usage extraction, account-error detection, mcp_ tool-name
|
|
898
|
+
* rewriting, and the L5 host-compat shim, so none of them can see
|
|
899
|
+
* different data than each other for the same event.
|
|
900
|
+
* @returns {string[]}
|
|
274
901
|
*/
|
|
275
|
-
function
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
if (boundary === -1) {
|
|
280
|
-
if (!flush) return;
|
|
281
|
-
if (!sseBuffer.trim()) {
|
|
282
|
-
sseBuffer = "";
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
902
|
+
function finalizeBlock() {
|
|
903
|
+
const lines = currentBlockLines;
|
|
904
|
+
currentBlockLines = [];
|
|
905
|
+
if (lines.length === 0) return lines;
|
|
286
906
|
|
|
287
|
-
|
|
288
|
-
|
|
907
|
+
const blockText = lines.join("\n");
|
|
908
|
+
const payload = getSSEDataPayload(blockText);
|
|
909
|
+
if (!payload) return lines;
|
|
289
910
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
911
|
+
let parsed;
|
|
912
|
+
try {
|
|
913
|
+
parsed = JSON.parse(payload);
|
|
914
|
+
} catch {
|
|
915
|
+
// Ignore malformed event payloads (pre-existing policy).
|
|
916
|
+
return lines;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (onUsage) {
|
|
920
|
+
extractUsageFromSSEEvent(parsed, stats);
|
|
921
|
+
}
|
|
922
|
+
if (onAccountError && !accountErrorHandled) {
|
|
923
|
+
const details = getMidStreamAccountError(parsed);
|
|
924
|
+
if (details) {
|
|
925
|
+
accountErrorHandled = true;
|
|
926
|
+
onAccountError(details);
|
|
294
927
|
}
|
|
928
|
+
}
|
|
295
929
|
|
|
296
|
-
|
|
297
|
-
|
|
930
|
+
let shimAction = "keep";
|
|
931
|
+
if (hostCompatShim) {
|
|
932
|
+
shimAction = applyHostCompatShim(parsed, hostCompatShimState);
|
|
933
|
+
} else if (onHostCompatShim) {
|
|
934
|
+
// QA fix L1: the shim is gated off for this (newer) host SDK, but run
|
|
935
|
+
// it anyway as a DRY RUN against a throwaway copy so a caller that
|
|
936
|
+
// wants to know can find out what it *would* have flagged. `parsed`
|
|
937
|
+
// itself, and therefore the real output, is never touched by this.
|
|
938
|
+
applyHostCompatShim(structuredClone(parsed), dryRunHostCompatShimState);
|
|
939
|
+
}
|
|
940
|
+
if (shimAction === "drop") return [];
|
|
298
941
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
942
|
+
const mcpModified = stripMcpPrefixFromParsedEvent(parsed);
|
|
943
|
+
if (shimAction === "rewrite" || mcpModified) {
|
|
944
|
+
// Re-serialize only the `data:` line(s) this event actually changed;
|
|
945
|
+
// every other line in the block (event:, id:, comments) is untouched.
|
|
946
|
+
// A payload combined from more than one `data:` line (spec-legal,
|
|
947
|
+
// never observed from Anthropic) collapses to a single rewritten line.
|
|
948
|
+
let replaced = false;
|
|
949
|
+
return lines
|
|
950
|
+
.map((line) => {
|
|
951
|
+
if (!/^data:\s*(.+)$/.test(line)) return line;
|
|
952
|
+
if (replaced) return null;
|
|
953
|
+
replaced = true;
|
|
954
|
+
return `data: ${JSON.stringify(parsed)}`;
|
|
955
|
+
})
|
|
956
|
+
.filter((line) => line !== null);
|
|
957
|
+
}
|
|
958
|
+
return lines;
|
|
959
|
+
}
|
|
302
960
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
961
|
+
/**
|
|
962
|
+
* Consume newly decoded text: split it into terminator-delimited lines
|
|
963
|
+
* (carrying any unresolved tail via `lineSplitState`), buffer each into
|
|
964
|
+
* the in-progress block, and finalize (parse + shim + emit) that block
|
|
965
|
+
* whenever a blank line closes it or `flush` forces a decision at EOF.
|
|
966
|
+
* @param {string} text
|
|
967
|
+
* @param {boolean} flush
|
|
968
|
+
* @returns {string}
|
|
969
|
+
*/
|
|
970
|
+
function processText(text, flush) {
|
|
971
|
+
const lines = splitTerminatedLines(lineSplitState, text, flush);
|
|
313
972
|
|
|
314
|
-
|
|
973
|
+
let out = "";
|
|
974
|
+
for (const line of lines) {
|
|
975
|
+
if (line === "") {
|
|
976
|
+
for (const emitted of finalizeBlock()) out += emitted + "\n";
|
|
977
|
+
out += "\n"; // the blank-line terminator itself, always preserved
|
|
978
|
+
} else {
|
|
979
|
+
currentBlockLines.push(line);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
if (flush) {
|
|
983
|
+
// EOF without a trailing blank line still ends whatever event was
|
|
984
|
+
// in progress; emit it rather than dropping it silently.
|
|
985
|
+
for (const emitted of finalizeBlock()) out += emitted + "\n";
|
|
315
986
|
}
|
|
987
|
+
return out;
|
|
316
988
|
}
|
|
317
989
|
|
|
318
990
|
/**
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
* @param {string} chunk
|
|
322
|
-
* @param {boolean} [flush]
|
|
323
|
-
* @returns {string}
|
|
991
|
+
* Append newly decoded text to the bounded SSE debug capture buffer.
|
|
992
|
+
* @param {string} text
|
|
324
993
|
*/
|
|
325
|
-
function
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
if (boundary === -1) return "";
|
|
331
|
-
const complete = sseRewriteBuffer.slice(0, boundary + 1);
|
|
332
|
-
sseRewriteBuffer = sseRewriteBuffer.slice(boundary + 1);
|
|
333
|
-
return stripMcpPrefixFromSSE(complete);
|
|
994
|
+
function appendCapture(text) {
|
|
995
|
+
const remaining = MAX_SSE_CAPTURE_BYTES - sseCaptureBuf.length;
|
|
996
|
+
if (remaining <= 0) {
|
|
997
|
+
sseCaptureTruncated = true;
|
|
998
|
+
return;
|
|
334
999
|
}
|
|
1000
|
+
sseCaptureBuf += text.length > remaining ? text.slice(0, remaining) : text;
|
|
1001
|
+
if (sseCaptureBuf.length >= MAX_SSE_CAPTURE_BYTES) sseCaptureTruncated = true;
|
|
1002
|
+
}
|
|
335
1003
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
1004
|
+
/**
|
|
1005
|
+
* Surface a decode failure as an explicit stream error: cancel the
|
|
1006
|
+
* upstream reader, never emit a fabricated success/usage, and never
|
|
1007
|
+
* trigger an account-specific callback or a replay — a local decoder
|
|
1008
|
+
* error must not penalize an account.
|
|
1009
|
+
* @param {ReadableStreamDefaultController} controller
|
|
1010
|
+
* @param {unknown} err
|
|
1011
|
+
* @param {string} where
|
|
1012
|
+
*/
|
|
1013
|
+
async function failOnDecodeError(controller, err, where) {
|
|
1014
|
+
try {
|
|
1015
|
+
await reader.cancel();
|
|
1016
|
+
} catch {
|
|
1017
|
+
// Reader may already be released; nothing to do.
|
|
1018
|
+
}
|
|
1019
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1020
|
+
controller.error(new Error(`invalid UTF-8 in response stream (${where}): ${message}`));
|
|
340
1021
|
}
|
|
341
1022
|
|
|
342
1023
|
return new ReadableStream({
|
|
343
1024
|
async pull(controller) {
|
|
344
1025
|
let readResult;
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
1026
|
+
try {
|
|
1027
|
+
readResult = await readWithIdleTimeout(reader, idleTimeoutMs);
|
|
1028
|
+
} catch (err) {
|
|
1029
|
+
// Idle timeout (or read error): cancel the upstream reader and surface a
|
|
1030
|
+
// clear error so the consumer can retry instead of waiting on a dead
|
|
1031
|
+
// connection. Mirrors CC's stream-idle watchdog (no silent hang).
|
|
348
1032
|
try {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
idleTimer = setTimeout(
|
|
353
|
-
() => reject(new Error(`stream idle timeout: no bytes for ${idleTimeoutMs}ms`)),
|
|
354
|
-
idleTimeoutMs,
|
|
355
|
-
);
|
|
356
|
-
}),
|
|
357
|
-
]);
|
|
358
|
-
} catch (err) {
|
|
359
|
-
// Idle timeout (or read error): cancel the upstream reader and surface a
|
|
360
|
-
// clear error so the consumer can retry instead of waiting on a dead
|
|
361
|
-
// connection. Mirrors CC's stream-idle watchdog (no silent hang).
|
|
362
|
-
try {
|
|
363
|
-
await reader.cancel();
|
|
364
|
-
} catch {
|
|
365
|
-
// Reader may already be released; nothing to do.
|
|
366
|
-
}
|
|
367
|
-
controller.error(err instanceof Error ? err : new Error(String(err)));
|
|
368
|
-
return;
|
|
369
|
-
} finally {
|
|
370
|
-
clearTimeout(idleTimer);
|
|
1033
|
+
await reader.cancel();
|
|
1034
|
+
} catch {
|
|
1035
|
+
// Reader may already be released; nothing to do.
|
|
371
1036
|
}
|
|
372
|
-
|
|
373
|
-
|
|
1037
|
+
controller.error(err instanceof Error ? err : new Error(String(err)));
|
|
1038
|
+
return;
|
|
374
1039
|
}
|
|
375
1040
|
const { done, value } = readResult;
|
|
376
1041
|
if (done) {
|
|
377
|
-
|
|
1042
|
+
let flushedText;
|
|
1043
|
+
try {
|
|
1044
|
+
// No args (stream:false): flush any incomplete trailing sequence.
|
|
1045
|
+
// A genuinely truncated/invalid tail throws here (see U5 contract
|
|
1046
|
+
// above) instead of vanishing silently.
|
|
1047
|
+
flushedText = decoder.decode();
|
|
1048
|
+
} catch (err) {
|
|
1049
|
+
await failOnDecodeError(controller, err, "end of stream");
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
378
1052
|
|
|
379
|
-
|
|
1053
|
+
if (captureEnabled && !sseCaptureTruncated && flushedText) {
|
|
1054
|
+
appendCapture(flushedText);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const rewrittenTail = processText(flushedText, true);
|
|
380
1058
|
if (rewrittenTail) {
|
|
381
1059
|
controller.enqueue(encoder.encode(rewrittenTail));
|
|
382
1060
|
}
|
|
@@ -387,6 +1065,34 @@ export function createTransformedSSEStream(
|
|
|
387
1065
|
) {
|
|
388
1066
|
onUsage(stats);
|
|
389
1067
|
}
|
|
1068
|
+
if (
|
|
1069
|
+
onHostCompatShim &&
|
|
1070
|
+
(hostCompatShimState.blockTypeFallbacks > 0 ||
|
|
1071
|
+
hostCompatShimState.deltasDropped > 0 ||
|
|
1072
|
+
hostCompatShimState.eventsDropped > 0)
|
|
1073
|
+
) {
|
|
1074
|
+
onHostCompatShim({
|
|
1075
|
+
blockTypeFallbacks: hostCompatShimState.blockTypeFallbacks,
|
|
1076
|
+
deltasDropped: hostCompatShimState.deltasDropped,
|
|
1077
|
+
eventsDropped: hostCompatShimState.eventsDropped,
|
|
1078
|
+
});
|
|
1079
|
+
} else if (
|
|
1080
|
+
// QA fix L1: the shim was gated off (a newer host SDK), but the dry
|
|
1081
|
+
// run above still found something it would have flagged — report
|
|
1082
|
+
// it with dryRun:true rather than staying silent.
|
|
1083
|
+
onHostCompatShim &&
|
|
1084
|
+
!hostCompatShim &&
|
|
1085
|
+
(dryRunHostCompatShimState.blockTypeFallbacks > 0 ||
|
|
1086
|
+
dryRunHostCompatShimState.deltasDropped > 0 ||
|
|
1087
|
+
dryRunHostCompatShimState.eventsDropped > 0)
|
|
1088
|
+
) {
|
|
1089
|
+
onHostCompatShim({
|
|
1090
|
+
blockTypeFallbacks: dryRunHostCompatShimState.blockTypeFallbacks,
|
|
1091
|
+
deltasDropped: dryRunHostCompatShimState.deltasDropped,
|
|
1092
|
+
eventsDropped: dryRunHostCompatShimState.eventsDropped,
|
|
1093
|
+
dryRun: true,
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
390
1096
|
if (captureEnabled) {
|
|
391
1097
|
try {
|
|
392
1098
|
await writeSseCapture(correlationId, sseCaptureBuf, sseCaptureTruncated);
|
|
@@ -398,25 +1104,19 @@ export function createTransformedSSEStream(
|
|
|
398
1104
|
return;
|
|
399
1105
|
}
|
|
400
1106
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
} else {
|
|
408
|
-
sseCaptureBuf += text.length > remaining ? text.slice(0, remaining) : text;
|
|
409
|
-
if (sseCaptureBuf.length >= MAX_SSE_CAPTURE_BYTES) sseCaptureTruncated = true;
|
|
410
|
-
}
|
|
1107
|
+
let text;
|
|
1108
|
+
try {
|
|
1109
|
+
text = decoder.decode(value, { stream: true });
|
|
1110
|
+
} catch (err) {
|
|
1111
|
+
await failOnDecodeError(controller, err, "mid-stream");
|
|
1112
|
+
return;
|
|
411
1113
|
}
|
|
412
1114
|
|
|
413
|
-
if (
|
|
414
|
-
|
|
415
|
-
sseBuffer += text.replace(/\r\n/g, "\n");
|
|
416
|
-
processSSEBuffer(false);
|
|
1115
|
+
if (captureEnabled && !sseCaptureTruncated && text) {
|
|
1116
|
+
appendCapture(text);
|
|
417
1117
|
}
|
|
418
1118
|
|
|
419
|
-
const rewrittenText =
|
|
1119
|
+
const rewrittenText = processText(text, false);
|
|
420
1120
|
if (rewrittenText) {
|
|
421
1121
|
controller.enqueue(encoder.encode(rewrittenText));
|
|
422
1122
|
} else {
|
|
@@ -425,5 +1125,9 @@ export function createTransformedSSEStream(
|
|
|
425
1125
|
controller.enqueue(EMPTY_CHUNK);
|
|
426
1126
|
}
|
|
427
1127
|
},
|
|
1128
|
+
cancel(reason) {
|
|
1129
|
+
// L4: propagate the consumer's cancellation to the upstream reader.
|
|
1130
|
+
return reader.cancel(reason);
|
|
1131
|
+
},
|
|
428
1132
|
});
|
|
429
1133
|
}
|