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.
@@ -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, (_match, jsonStr) => {
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
- { onUsage, onAccountError, correlationId, idleTimeoutMs, captureEnabled, writeSseCapture },
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
- const decoder = new TextDecoder();
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
- let sseBuffer = "";
268
- let sseRewriteBuffer = "";
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
- * Process buffered SSE event blocks.
273
- * @param {boolean} flush
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 processSSEBuffer(flush = false) {
276
- while (true) {
277
- const boundary = sseBuffer.indexOf("\n\n");
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
- const eventBlock = boundary === -1 ? sseBuffer : sseBuffer.slice(0, boundary);
288
- sseBuffer = boundary === -1 ? "" : sseBuffer.slice(boundary + 2);
907
+ const blockText = lines.join("\n");
908
+ const payload = getSSEDataPayload(blockText);
909
+ if (!payload) return lines;
289
910
 
290
- const payload = getSSEDataPayload(eventBlock);
291
- if (!payload) {
292
- if (boundary === -1) return;
293
- continue;
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
- try {
297
- const parsed = JSON.parse(payload);
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
- if (onUsage) {
300
- extractUsageFromSSEEvent(parsed, stats);
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
- if (onAccountError && !accountErrorHandled) {
304
- const details = getMidStreamAccountError(parsed);
305
- if (details) {
306
- accountErrorHandled = true;
307
- onAccountError(details);
308
- }
309
- }
310
- } catch {
311
- // Ignore malformed event payloads.
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
- if (boundary === -1) return;
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
- * Rewrite complete SSE lines while preserving chunk boundaries for streaming.
320
- * Buffers trailing partial lines to avoid parsing split JSON payloads.
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 rewriteSSEChunk(chunk, flush = false) {
326
- sseRewriteBuffer += chunk;
327
-
328
- if (!flush) {
329
- const boundary = sseRewriteBuffer.lastIndexOf("\n");
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
- if (!sseRewriteBuffer) return "";
337
- const finalText = stripMcpPrefixFromSSE(sseRewriteBuffer);
338
- sseRewriteBuffer = "";
339
- return finalText;
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
- if (idleTimeoutMs > 0) {
346
- /** @type {ReturnType<typeof setTimeout> | undefined} */
347
- let idleTimer;
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
- readResult = await Promise.race([
350
- reader.read(),
351
- new Promise((_resolve, reject) => {
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
- } else {
373
- readResult = await reader.read();
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
- processSSEBuffer(true);
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
- const rewrittenTail = rewriteSSEChunk("", true);
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
- const text = decoder.decode(value, { stream: true });
402
-
403
- if (captureEnabled && !sseCaptureTruncated && text) {
404
- const remaining = MAX_SSE_CAPTURE_BYTES - sseCaptureBuf.length;
405
- if (remaining <= 0) {
406
- sseCaptureTruncated = true;
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 (onUsage || onAccountError) {
414
- // Normalize CRLF for parser only; preserve original bytes for passthrough.
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 = rewriteSSEChunk(text, false);
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
  }