pi-freeflow 1.32.0 → 1.32.2

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/src/relay.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { Buffer } from "node:buffer";
7
+ import { Agent } from "undici";
7
8
  import { UPSTREAM_HEADER_TIMEOUT_MS } from "./config.ts";
8
9
  import { isDebugEnabled, log } from "./logger.ts";
9
10
  import {
@@ -23,6 +24,44 @@ import {
23
24
  // one warning instead of a wall of identical toasts.
24
25
  let lastRollNotify = 0;
25
26
  const ROLL_NOTIFY_MS = 5 * 60 * 1_000;
27
+ /********************************************************
28
+ * Warm client-to-relay dispatcher: one shared undici Agent
29
+ * with long keep-alive so repeat relay attempts reuse
30
+ * CONNECT/TLS across hot turns instead of paying a fresh handshake
31
+ * per attempt. Relay leg only - the direct-to-upstream
32
+ * fallback keeps the built-in global dispatcher. headersTimeout
33
+ * reaps wedged tunnels, bodyTimeout 0 disables per-chunk kill
34
+ * (the 300s total backstop still bounds a turn), connections 50
35
+ * caps burst spray.
36
+ *
37
+ * Compat: an npm undici Agent is rejected as a `dispatcher`
38
+ * by an older-major global fetch bundled in Node
39
+ * (`invalid onRequestStart method`, cf. commit 7cbbb37), so
40
+ * the custom dispatcher attaches only when the bundled
41
+ * undici major covers the npm major (both 7 here); older
42
+ * Node keeps the built-in global dispatcher, which already
43
+ * pools keep-alive.
44
+ ********************************************************/
45
+ let relayAgent: Agent | null = null;
46
+ /** True when the bundled undici accepts the npm undici 7.x Agent as dispatcher. */
47
+ export const canUseRelayDispatcher =
48
+ Number((process.versions.undici ?? "0").split(".")[0]) >= 7;
49
+ function getRelayAgent(): Agent {
50
+ if (!relayAgent) {
51
+ relayAgent = new Agent({ keepAliveTimeout: 60_000, connectTimeout: 10_000, headersTimeout: 60_000, bodyTimeout: 0, connections: 50 });
52
+ }
53
+ return relayAgent;
54
+ }
55
+ /** Extra fetch init for relay-leg attempts; empty when the custom dispatcher is unavailable. */
56
+ function relayLegInit(): { dispatcher?: Agent } {
57
+ return canUseRelayDispatcher ? { dispatcher: getRelayAgent() } : {};
58
+ }
59
+ /** Test-only: close the shared relay Agent so its sockets never leak across tests. */
60
+ export async function _closeRelayDispatcherForTest(): Promise<void> {
61
+ const agent = relayAgent;
62
+ relayAgent = null;
63
+ if (agent) await agent.close().catch(() => {});
64
+ }
26
65
  /********************************************************
27
66
  * Hedged failover grace: an attempt with no response headers
28
67
  * within this window fires the NEXT candidate in parallel.
@@ -120,6 +159,8 @@ export async function relayFetch(
120
159
  let lastResponseRelay: string | null = null;
121
160
  /** Relay URLs that returned a gated 402 DEPLOYMENT_DISABLED this request. */
122
161
  const disabledRelays: string[] = [];
162
+ /** Bare-500 roll-once cap: one extra attempt per relayFetch turn. */
163
+ let sawBare500 = false;
123
164
  const u = new URL(url);
124
165
  const relayTarget = `${u.protocol}//${u.host}`;
125
166
  const relayPath = `${u.pathname}${u.search}`;
@@ -220,14 +261,14 @@ export async function relayFetch(
220
261
  if (!canHedge) {
221
262
  const soloCtl = new AbortController();
222
263
  const solo = buildAttempt(targetUrl, soloCtl);
223
- res = await fetch(targetUrl, { ...opts, headers: solo.headers, signal: solo.signal } as unknown as RequestInit);
264
+ res = await fetch(targetUrl, { ...opts, headers: solo.headers, signal: solo.signal, ...relayLegInit() } as unknown as RequestInit);
224
265
  } else {
225
266
  const primaryUrl = targetUrl;
226
267
  const nextUrl = candidates[i + 1];
227
268
  const nextCheck = validateRelayUrl(nextUrl);
228
269
  const hedgeCtlA = new AbortController();
229
270
  const first = buildAttempt(primaryUrl, hedgeCtlA);
230
- const fetchA = fetch(primaryUrl, { ...opts, headers: first.headers, signal: first.signal } as unknown as RequestInit);
271
+ const fetchA = fetch(primaryUrl, { ...opts, headers: first.headers, signal: first.signal, ...relayLegInit() } as unknown as RequestInit);
231
272
  const stalled = await new Promise<boolean>((resolve) => {
232
273
  const graceTimer = setTimeout(() => resolve(true), HEDGE_GRACE_MS);
233
274
  fetchA.then(
@@ -242,7 +283,7 @@ export async function relayFetch(
242
283
  const hedgeCtlB = new AbortController();
243
284
  const second = buildAttempt(nextUrl, hedgeCtlB);
244
285
  const hedgeStart = Date.now();
245
- const fetchB = fetch(nextUrl, { ...opts, headers: second.headers, signal: second.signal } as unknown as RequestInit);
286
+ const fetchB = fetch(nextUrl, { ...opts, headers: second.headers, signal: second.signal, ...relayLegInit() } as unknown as RequestInit);
246
287
  // Swallow the loser path so the deliberate abort never
247
288
  // surfaces as an unhandled rejection or a direct fallback.
248
289
  fetchA.catch(() => {});
@@ -430,6 +471,23 @@ export async function relayFetch(
430
471
  continue;
431
472
  }
432
473
 
474
+ // Bounded bare-500 roll-once: a transient model-side 500 carries no
475
+ // relay-fault signal (413 precedent: roll without health penalty), so
476
+ // retry once on the next candidate instead of surfacing. A second
477
+ // consecutive 500 falls through to the terminal passthrough below.
478
+ if (res.status === 500 && !sawBare500) {
479
+ sawBare500 = true;
480
+ lastResponse?.body?.cancel().catch(() => {});
481
+ lastResponse = res;
482
+ lastResponseRelay = targetUrl;
483
+ log(
484
+ "warn",
485
+ `relay ${targetUrl} returned HTTP 500 in ${elapsed}s — rolling once to next relay`,
486
+ { upstream: url, status: 500 },
487
+ rid,
488
+ );
489
+ continue;
490
+ }
433
491
  markRelaySuccess(targetUrl, Date.now() - attemptStart);
434
492
 
435
493
  // SUCCESS or non-retriable client error (e.g. 200, 404):
@@ -25,10 +25,79 @@ import { restoreToolNameForCaller } from "./tool-translation.ts";
25
25
  */
26
26
  export const SUBSTANTIAL_MIN_CHUNKS = 50;
27
27
  export const SUBSTANTIAL_MIN_BYTES = 100 * 1024;
28
+ // Wire markers proving the forwarded bytes already contain a tool call the
29
+ // host may execute (responses + chat shapes). A re-fired turn would
30
+ // regenerate those calls, so their presence vetoes the silent resume and
31
+ // the turn keeps the synthetic-terminal behavior. Text/reasoning turns are
32
+ // safe to resume: nothing was executed yet.
33
+ const TOOL_CALL_MARKERS = [
34
+ '"type":"function_call"',
35
+ '"type":"custom_tool_call"',
36
+ '"type":"tool_use"',
37
+ "function_call_arguments",
38
+ "custom_tool_call_input",
39
+ '"tool_calls"',
40
+ ];
28
41
 
29
42
  export function isSubstantial(chunks: number, bytes: number): boolean {
30
43
  return chunks > SUBSTANTIAL_MIN_CHUNKS && bytes > SUBSTANTIAL_MIN_BYTES;
31
44
  }
45
+ // Markers identifying an upstream-issued mid-stream model failure worth one
46
+ // silent resume on a sibling relay (transient model flake, not a caller or
47
+ // upstream verdict). Kept narrow on purpose: anything shaped like a request,
48
+ // auth, quota, or reasoning-reference problem stays terminal and is
49
+ // forwarded untouched.
50
+ const TRANSIENT_MODEL_FAILURE_MARKERS = [
51
+ "server_error",
52
+ "failed to generate",
53
+ "overloaded",
54
+ ];
55
+ // Deterministic verdicts that must NEVER resume: retrying would replay the
56
+ // same refusal while masking a quota/auth/gate problem as relay trouble.
57
+ const DETERMINISTIC_FAILURE_MARKERS = [
58
+ "invalid_request",
59
+ "invalid_response",
60
+ "invalid_reasoning",
61
+ "previous_response",
62
+ "FreeTierError",
63
+ "rate_limit",
64
+ "quota",
65
+ "billing",
66
+ "authentication",
67
+ "invalid_api_key",
68
+ "permission",
69
+ "moderation",
70
+ ];
71
+
72
+ /**
73
+ * True when scanned SSE text carries an upstream-issued response.failed frame
74
+ * for a TRANSIENT model fault (server_error class). Text-only turns holding
75
+ * such a frame may offer one silent re-fire; anything else (clean terminals,
76
+ * deterministic verdicts, chat shapes) keeps existing behavior.
77
+ */
78
+ export function isTransientModelFailureFrame(s: string): boolean {
79
+ if (!s.includes("response.failed") && !s.includes('"status":"failed"')) return false;
80
+ for (const m of DETERMINISTIC_FAILURE_MARKERS) if (s.includes(m)) return false;
81
+ for (const m of TRANSIENT_MODEL_FAILURE_MARKERS) if (s.includes(m)) return true;
82
+ return false;
83
+ }
84
+
85
+ // Mid-stream fault split (observability only, never routing): upstream-issued
86
+ // model-fault frames vs relay-fault truncations. Relay-health marks stay the
87
+ // rotation mechanism; these counters attribute blame for status/logs.
88
+ let midStreamModelFaults = 0;
89
+ let midStreamRelayTruncations = 0;
90
+
91
+ /** Current mid-stream fault split. */
92
+ export function getMidStreamFaultStats(): { modelFaults: number; relayTruncations: number } {
93
+ return { modelFaults: midStreamModelFaults, relayTruncations: midStreamRelayTruncations };
94
+ }
95
+
96
+ /** Test-only: reset the mid-stream fault split. */
97
+ export function _resetMidStreamFaultStatsForTest(): void {
98
+ midStreamModelFaults = 0;
99
+ midStreamRelayTruncations = 0;
100
+ }
32
101
 
33
102
  /**
34
103
  * Failed-SSE rolling window (client watchdog input).
@@ -492,6 +561,22 @@ export function rewriteSseBlock(
492
561
  if (cloak.pathname.endsWith("/messages")) return rewriteMessagesBlock(block, state, cloak);
493
562
  return rewriteChatBlock(block, state, cloak);
494
563
  }
564
+ /**
565
+ * Options for pipeUpstreamStream.
566
+ */
567
+ export interface StreamPipeOptions {
568
+ /**
569
+ * Mid-stream resume hook. Invoked at most once per turn, only when the
570
+ * upstream truncates (clean FIN without a terminal marker, premature
571
+ * socket close, or a genuine stream error) while the client is still
572
+ * attached. Return a replacement upstream readable to continue THIS turn
573
+ * on it — no synthetic terminal is injected and the client response stays
574
+ * open. Return null (or throw) to keep the existing terminate behavior:
575
+ * a synthetic response.incomplete/failed plus relay-health accounting.
576
+ * Client aborts and clean terminal markers never reach this hook.
577
+ */
578
+ refire?: () => Promise<Readable | null>;
579
+ }
495
580
  /**
496
581
  * Pipes an upstream readable stream to a client HTTP response.
497
582
  *
@@ -501,6 +586,9 @@ export function rewriteSseBlock(
501
586
  * - Cleans up resources and destroys upstream stream on client disconnect/abort.
502
587
  * - Optional per-request `cloak`: rewrites SSE events inline (placeholder
503
588
  * strip + caller-name restore); bypassed streams flow byte-identical.
589
+ * - Optional `opts.refire`: one silent mid-stream resume on upstream
590
+ * truncation (see StreamPipeOptions); without it the turn ends with a
591
+ * synthetic terminal event exactly as before.
504
592
  */
505
593
  export function pipeUpstreamStream(
506
594
  nodeStream: Readable,
@@ -509,6 +597,7 @@ export function pipeUpstreamStream(
509
597
  reqId?: string,
510
598
  relayUrl?: string,
511
599
  cloak?: StreamCloakOptions,
600
+ opts?: StreamPipeOptions,
512
601
  ): void {
513
602
  const rid = reqId || randomUUID().slice(0, 8);
514
603
  let totalChunks = 0;
@@ -523,11 +612,19 @@ export function pipeUpstreamStream(
523
612
  // relay health. Client disconnects and clean upstream ends must not.
524
613
  let upstreamEnded = false;
525
614
  let clientAborted = false;
615
+ // Set once forwarded bytes carry a tool call (see TOOL_CALL_MARKERS): a
616
+ // resumed turn would regenerate — and the host would re-execute — those
617
+ // calls, so tryRefire refuses while this is set.
618
+ let sawToolCall = false;
526
619
  // Terminal-marker scan carry: holds the tail of the previously scanned
527
620
  // view so a marker split across adjacent chunks ("[DO" | "NE]") is still
528
621
  // recognized. Longest marker is "response.completed" (18 bytes); 32
529
622
  // gives comfortable headroom.
530
623
  let terminalScanCarry = Buffer.alloc(0);
624
+ // True while a replacement stream is being fetched: the stalled close of the
625
+ // truncated stream must not inject a terminal or end res mid-rescue (its
626
+ // `end` already ran; `close` always trails it on a later tick).
627
+ let refirePending = false;
531
628
  // Streaming cloak: per-stream rewrite state (null = byte-identical passthrough).
532
629
  const cloakState = cloak && !shouldBypassStreamCloak(cloak) ? createSseStreamCloakState() : null;
533
630
  // Holds the trailing incomplete SSE block across chunks; complete blocks are
@@ -543,6 +640,8 @@ export function pipeUpstreamStream(
543
640
  outcomeRecorded = true;
544
641
  recordSseOutcome(failed);
545
642
  };
643
+ // One-shot resume guard: the refire hook below fires at most once per turn.
644
+ let refireUsed = false;
546
645
 
547
646
  const sniffThinking = (chunk: Buffer | string): boolean => {
548
647
  const s =
@@ -568,6 +667,92 @@ export function pipeUpstreamStream(
568
667
  s.includes("[DONE]")
569
668
  );
570
669
  };
670
+ // Held transient model-failure frame (raw chunk or rewritten block text):
671
+ // withheld from the client while one silent resume is attempted. Dropped on
672
+ // success, forwarded verbatim (as the genuine upstream verdict) when the
673
+ // resume is vetoed, unavailable, or fails. Null outside a hold.
674
+ let heldFailureChunk: string | Buffer | null = null;
675
+ /**
676
+ * Hold an upstream-issued transient model-failure frame for one silent
677
+ * resume on a sibling relay. Returns true when held (caller must NOT
678
+ * forward the frame); false keeps the existing terminate behavior. Tool
679
+ * turns always veto: a regenerated turn would re-execute forwarded calls.
680
+ */
681
+ const holdModelFailureForRefire = (chunk: string | Buffer): boolean => {
682
+ if (refirePending || heldFailureChunk !== null || sawToolCall) return false;
683
+ if (clientAborted || refireUsed || !opts?.refire || res.writableEnded || res.destroyed) return false;
684
+ refireUsed = true;
685
+ refirePending = true;
686
+ heldFailureChunk = chunk;
687
+ midStreamModelFaults += 1;
688
+ log(
689
+ "warn",
690
+ "upstream model failed this turn mid-stream (model fault) — retrying once on the next relay",
691
+ { totalChunks, totalBytes },
692
+ rid,
693
+ );
694
+ void rescueHeldModelFailure();
695
+ return true;
696
+ };
697
+ /**
698
+ * Deliver a held failure frame after the resume failed: the same
699
+ * scan/mark/forward as a normal chunk, minus any further hold (the
700
+ * one-resume budget is spent). Never penalizes relay health — the failure
701
+ * is an upstream model verdict, not a relay fault.
702
+ */
703
+ const releaseHeldFailure = (): void => {
704
+ const held = heldFailureChunk;
705
+ heldFailureChunk = null;
706
+ if (held === null || res.writableEnded) return;
707
+ const buf = typeof held === "string" ? Buffer.from(held, "utf8") : held;
708
+ const scanBuf =
709
+ terminalScanCarry.length > 0
710
+ ? Buffer.concat([terminalScanCarry, buf])
711
+ : buf;
712
+ const scanned = scanBuf.toString("utf8");
713
+ if (!hasTerminalEvent && checkTerminalEvent(scanned)) hasTerminalEvent = true;
714
+ if (!sawToolCall && TOOL_CALL_MARKERS.some((m) => scanned.includes(m))) sawToolCall = true;
715
+ terminalScanCarry = Buffer.from(scanBuf.subarray(Math.max(0, scanBuf.length - 32)));
716
+ try {
717
+ res.write(held);
718
+ const maybeFlush = res as unknown as { flush?: () => void };
719
+ if (typeof maybeFlush.flush === "function") maybeFlush.flush();
720
+ } catch { }
721
+ };
722
+ /**
723
+ * Resume a held model-failure turn on the replacement stream from
724
+ * opts.refire (rotation marking lives there, shared with the truncation
725
+ * path). Success drops the held frame; the stale cut stream's later
726
+ * events are neutered by the generation check in watch().
727
+ */
728
+ const rescueHeldModelFailure = async (): Promise<void> => {
729
+ const refire = opts?.refire;
730
+ let next: Readable | null = null;
731
+ try {
732
+ next = refire ? await refire() : null;
733
+ } catch {
734
+ next = null;
735
+ }
736
+ refirePending = false;
737
+ if (!next || next.destroyed || clientAborted || res.writableEnded || res.destroyed) {
738
+ try {
739
+ next?.destroy();
740
+ } catch { }
741
+ releaseHeldFailure();
742
+ return;
743
+ }
744
+ heldFailureChunk = null;
745
+ const cut = nodeStream;
746
+ upstreamEnded = false;
747
+ nodeStream = next;
748
+ sseBuffer = "";
749
+ terminalScanCarry = Buffer.alloc(0);
750
+ watch(next);
751
+ try {
752
+ if (!cut.destroyed) cut.destroy();
753
+ } catch { }
754
+ log("debug", "mid-stream model-fault refire: continuing turn on replacement stream", { totalChunks, totalBytes }, rid);
755
+ };
571
756
  /**
572
757
  * Forward one rewritten SSE block: terminal-marker scan plus the shared
573
758
  * backpressure/flush handling, so cloaked streams keep the raw path's
@@ -579,7 +764,13 @@ export function pipeUpstreamStream(
579
764
  terminalScanCarry.length > 0
580
765
  ? Buffer.concat([terminalScanCarry, buf])
581
766
  : buf;
582
- if (!hasTerminalEvent && checkTerminalEvent(scanBuf.toString("utf8"))) {
767
+ const scanned = scanBuf.toString("utf8");
768
+ if (!sawToolCall && TOOL_CALL_MARKERS.some((m) => scanned.includes(m))) sawToolCall = true;
769
+ if (!hasTerminalEvent && checkTerminalEvent(scanned)) {
770
+ // Upstream-issued transient model failure on a text-only turn: hold the
771
+ // frame for one silent resume instead of terminating (tool turns veto
772
+ // inside holdModelFailureForRefire and keep the terminal below).
773
+ if (isTransientModelFailureFrame(scanned) && holdModelFailureForRefire(text)) return;
583
774
  hasTerminalEvent = true;
584
775
  }
585
776
  terminalScanCarry = Buffer.from(scanBuf.subarray(Math.max(0, scanBuf.length - 32)));
@@ -608,17 +799,19 @@ export function pipeUpstreamStream(
608
799
  // Only genuine upstream-side truncation penalizes relay health.
609
800
  // Client disconnects and clean upstream ends leave it untouched.
610
801
  if (penalizeRelay && relayUrl && relayUrl !== "direct") {
802
+ // Relay-fault side of the mid-stream split (model faults never reach here).
803
+ midStreamRelayTruncations += 1;
611
804
  markRelayFailure(relayUrl, 0, errorMsg || "stream truncated prematurely");
612
805
  }
613
806
  if (isResponsesApi && totalChunks > 0) {
614
807
  try {
615
808
  if (isError) {
616
809
  res.write(
617
- `\nevent: response.failed\ndata: {"type":"response.failed","response":{"status":"failed","error":{"code":"stream_error","message":${JSON.stringify(errorMsg || "Upstream stream disconnected unexpectedly")}}}}\n\n`,
810
+ `\n\nevent: response.failed\ndata: {"type":"response.failed","response":{"status":"failed","error":{"code":"stream_error","message":${JSON.stringify(errorMsg || "Upstream stream disconnected unexpectedly")}}}}\n\n`,
618
811
  );
619
812
  } else {
620
813
  res.write(
621
- `\nevent: response.incomplete\ndata: {"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"cancelled"}}}\n\n`,
814
+ `\n\nevent: response.incomplete\ndata: {"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"cancelled"}}}\n\n`,
622
815
  );
623
816
  }
624
817
  hasTerminalEvent = true;
@@ -631,19 +824,61 @@ export function pipeUpstreamStream(
631
824
  } catch { }
632
825
  } else if (!isResponsesApi && totalChunks > 0) {
633
826
  try {
634
- res.write("\ndata: [DONE]\n\n");
827
+ res.write("\n\ndata: [DONE]\n\n");
635
828
  hasTerminalEvent = true;
636
829
  } catch { }
637
830
  }
638
831
  };
832
+ /**
833
+ * One silent mid-stream resume. When the upstream truncates with the client
834
+ * still attached, asks the proxy (via opts.refire) for a replacement stream
835
+ * and continues THIS turn on it — no synthetic terminal, res stays open.
836
+ * Returns true when the turn continues; false keeps the existing
837
+ * terminate-with-synthetic-terminal behavior at the call site. At most one
838
+ * resume per turn; the proxy additionally budgets re-fires, so a dying pool
839
+ * degrades to one synthetic terminal instead of a retry storm.
840
+ */
841
+ const tryRefire = async (): Promise<boolean> => {
842
+ if (clientAborted || refireUsed || !opts?.refire) return false;
843
+ if (sawToolCall) {
844
+ log("debug", "mid-stream refire skipped: turn already forwarded tool calls", { totalChunks, totalBytes }, rid);
845
+ return false;
846
+ }
847
+ if (res.writableEnded || res.destroyed) return false;
848
+ refireUsed = true;
849
+ refirePending = true;
850
+ let next: Readable | null = null;
851
+ try {
852
+ next = await opts.refire();
853
+ } catch {
854
+ next = null;
855
+ }
856
+ refirePending = false;
857
+ if (!next || next.destroyed || clientAborted || res.writableEnded || res.destroyed) {
858
+ try {
859
+ next?.destroy();
860
+ } catch { }
861
+ return false;
862
+ }
863
+ upstreamEnded = false;
864
+ nodeStream = next;
865
+ sseBuffer = "";
866
+ terminalScanCarry = Buffer.alloc(0);
867
+ watch(next);
868
+ log("debug", "mid-stream refire: continuing turn on replacement stream", { totalChunks, totalBytes }, rid);
869
+ return true;
870
+ };
639
871
 
640
872
  try {
641
873
  if (typeof res.flushHeaders === "function") {
642
874
  res.flushHeaders();
643
875
  }
644
876
  } catch { }
645
- nodeStream.on("data", (chunk: Buffer | string) => {
877
+ const onData = (chunk: Buffer | string): void => {
646
878
  try {
879
+ // While a replacement stream is being fetched the cut stream is stale:
880
+ // its late frames must not interleave with the rescued turn.
881
+ if (refirePending) return;
647
882
  if (firstChunkAt === null) {
648
883
  firstChunkAt = Date.now();
649
884
  const ttfb = firstChunkAt - startAt;
@@ -671,58 +906,30 @@ export function pipeUpstreamStream(
671
906
  );
672
907
  }
673
908
  }
674
- if (cloakState && cloak) {
675
- // Cloaked stream: buffer across chunks so event names split mid-chunk
676
- // still match, rewrite complete SSE blocks, and drop injected calls.
677
- sseBuffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
678
- let boundary = sseBoundary.exec(sseBuffer);
679
- while (boundary) {
680
- const block = sseBuffer.slice(0, boundary.index);
681
- sseBuffer = sseBuffer.slice(boundary.index + boundary[0].length);
909
+ // Buffer across chunks into complete SSE blocks delimited by double-newline.
910
+ // Forwarding only complete blocks guarantees that network chunk boundaries
911
+ // never split JSON strings on the wire or leave unterminated syntax when
912
+ // mid-stream refire or truncation occurs.
913
+ sseBuffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
914
+ let boundary = sseBoundary.exec(sseBuffer);
915
+ while (boundary) {
916
+ const block = sseBuffer.slice(0, boundary.index);
917
+ sseBuffer = sseBuffer.slice(boundary.index + boundary[0].length);
918
+ if (cloakState && cloak) {
682
919
  const rewritten = rewriteSseBlock(block, cloakState, cloak);
683
920
  if (rewritten !== null) forwardText(`${rewritten}\n\n`);
684
- boundary = sseBoundary.exec(sseBuffer);
921
+ } else {
922
+ forwardText(`${block}\n\n`);
685
923
  }
686
- return;
687
- }
688
- // Always scan the FULL chunk plus the small carry from the previous
689
- // view. SSE chunks are KBs at most, so includes() over everything
690
- // is negligible against correctness — the removed head/tail windows
691
- // are exactly what let markers buried mid-chunk or split across
692
- // adjacent chunks escape and cause duplicate synthetic terminals.
693
- const buf =
694
- typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
695
- const scanBuf =
696
- terminalScanCarry.length > 0
697
- ? Buffer.concat([terminalScanCarry, buf])
698
- : buf;
699
- if (!hasTerminalEvent && checkTerminalEvent(scanBuf.toString("utf8"))) {
700
- hasTerminalEvent = true;
701
- }
702
- terminalScanCarry = Buffer.from(scanBuf.subarray(Math.max(0, scanBuf.length - 32)));
703
-
704
- const canContinue = res.write(chunk);
705
- if (canContinue === false && !nodeStream.destroyed) {
706
- // Backpressure: pause the upstream source until the client
707
- // socket drains, instead of buffering an unbounded amount into
708
- // the response. 'drain' might never fire if the client closes —
709
- // the close handlers below destroy the stream regardless of
710
- // its paused state.
711
- nodeStream.pause();
712
- const onDrain = () => {
713
- res.off("drain", onDrain);
714
- if (!nodeStream.destroyed) nodeStream.resume();
715
- };
716
- res.once("drain", onDrain);
717
- }
718
- const maybeFlush = res as unknown as { flush?: () => void };
719
- if (typeof maybeFlush.flush === "function") {
720
- maybeFlush.flush();
924
+ boundary = sseBoundary.exec(sseBuffer);
721
925
  }
722
926
  } catch { }
723
- });
927
+ };
724
928
 
725
- nodeStream.on("error", (e: unknown) => {
929
+ const onError = async (e: unknown): Promise<void> => {
930
+ // A rescue is already in flight (its outcome decides the turn): a late
931
+ // error from the cut stream must not inject a terminal mid-rescue.
932
+ if (refirePending) return;
726
933
  const errorMsg = (e as Error)?.message || String(e);
727
934
  // An abort (client disconnect or a proxy-internal header timeout marked
728
935
  // FF_INTERNAL_ABORT) is not a relay fault — never penalize relay health,
@@ -745,29 +952,48 @@ export function pipeUpstreamStream(
745
952
  res.writeHead(502, { "content-type": "application/json" });
746
953
  } else if (isInternalAbort) {
747
954
  clientAborted = true;
955
+ sseBuffer = "";
956
+ terminalScanCarry = Buffer.alloc(0);
748
957
  ensureTerminalEvent(false, errorMsg || "stream interrupted", false);
749
958
  } else {
959
+ if (await tryRefire()) return;
960
+ sseBuffer = "";
961
+ terminalScanCarry = Buffer.alloc(0);
750
962
  ensureTerminalEvent(!isSubstantial(totalChunks, totalBytes), errorMsg, true);
751
963
  }
752
964
  if (!res.writableEnded) {
753
965
  res.end();
754
966
  }
755
967
  } catch { }
756
- });
968
+ };
757
969
 
758
- nodeStream.on("end", () => {
970
+ const onEnd = async (): Promise<void> => {
971
+ // A rescue is in flight (its outcome owns the turn): the cut stream's FIN
972
+ // must neither inject a terminal nor end res mid-rescue.
973
+ if (refirePending) return;
759
974
  upstreamEnded = true;
760
975
  // Cloaked streams hold the trailing incomplete block: flush it (rewritten)
761
976
  // ahead of the terminal-marker check so a coalesced final event still counts.
762
- if (cloakState && cloak && sseBuffer.trim() !== "") {
977
+ if (sseBuffer.trim() !== "") {
763
978
  const tail = sseBuffer;
764
979
  sseBuffer = "";
765
- const rewritten = rewriteSseBlock(tail, cloakState, cloak);
766
- if (rewritten !== null) forwardText(`${rewritten}\n\n`);
980
+ if (checkTerminalEvent(tail)) {
981
+ if (cloakState && cloak) {
982
+ const rewritten = rewriteSseBlock(tail, cloakState, cloak);
983
+ if (rewritten !== null) forwardText(`${rewritten}\n\n`);
984
+ } else {
985
+ forwardText(tail);
986
+ }
987
+ }
767
988
  }
768
989
  if (hasTerminalEvent) recordOnce(false);
769
990
  const elapsed = ((Date.now() - startAt) / 1000).toFixed(1);
770
991
  if (!hasTerminalEvent && totalChunks > 0) {
992
+ // Upstream FIN without a terminal marker: offer the proxy one silent
993
+ // re-fire on a sibling relay before falling back to the synthetic
994
+ // terminal below. A resumed turn keeps streaming into the same client
995
+ // response; the final outcome is logged when that stream ends.
996
+ if (await tryRefire()) return;
771
997
  // Clean upstream end without a detectable marker: keep the host
772
998
  // contract (synthetic terminal) but never blame the relay.
773
999
  ensureTerminalEvent(
@@ -794,16 +1020,26 @@ export function pipeUpstreamStream(
794
1020
  try {
795
1021
  if (!res.writableEnded) res.end();
796
1022
  } catch { }
797
- });
1023
+ };
798
1024
 
799
- nodeStream.on("close", () => {
1025
+ const onClose = async (): Promise<void> => {
1026
+ // A stalled `close` trailing an `end` that is mid-rescue must neither
1027
+ // inject a terminal nor end res — the replacement stream owns the turn now.
1028
+ if (refirePending) return;
800
1029
  try {
801
1030
  if (!hasTerminalEvent && totalChunks > 0) {
802
1031
  if (clientAborted || upstreamEnded) {
803
1032
  // Client disconnect teardown or post-end cleanup — the relay
804
1033
  // is not at fault; still give the host a terminal event.
1034
+ sseBuffer = "";
1035
+ terminalScanCarry = Buffer.alloc(0);
805
1036
  ensureTerminalEvent(false, "stream interrupted by client", false);
806
1037
  } else {
1038
+ // Same offer as the clean-end path above: one silent re-fire before
1039
+ // marking this relay and terminating the turn.
1040
+ if (await tryRefire()) return;
1041
+ sseBuffer = "";
1042
+ terminalScanCarry = Buffer.alloc(0);
807
1043
  // Upstream socket died mid-stream with no error event.
808
1044
  // For muse-spark large payloads: raxtant 514KB failed but feoni 802KB
809
1045
  // succeeded with same 2.6MB in — so this is edge-specific, not pure
@@ -815,7 +1051,16 @@ export function pipeUpstreamStream(
815
1051
  }
816
1052
  if (!res.writableEnded) res.end();
817
1053
  } catch { }
818
- });
1054
+ };
1055
+ const watch = (s: Readable): void => {
1056
+ // Generation check: after a rescue swaps nodeStream, the cut stream's
1057
+ // late events are stale and must not touch the rescued turn.
1058
+ s.on("data", (c) => { if (s === nodeStream) onData(c); });
1059
+ s.on("error", (e) => { if (s === nodeStream) void onError(e); });
1060
+ s.on("end", () => { if (s === nodeStream) void onEnd(); });
1061
+ s.on("close", () => { if (s === nodeStream) void onClose(); });
1062
+ };
1063
+ watch(nodeStream);
819
1064
 
820
1065
  req.on("aborted", () => {
821
1066
  clientAborted = true;
@@ -824,7 +1069,7 @@ export function pipeUpstreamStream(
824
1069
  });
825
1070
 
826
1071
  req.on("close", () => {
827
- if (!upstreamEnded && !nodeStream.destroyed) {
1072
+ if ((!upstreamEnded || refirePending) && !nodeStream.destroyed) {
828
1073
  clientAborted = true;
829
1074
  nodeStream.destroy();
830
1075
  }
@@ -832,7 +1077,7 @@ export function pipeUpstreamStream(
832
1077
 
833
1078
  res.on("close", () => {
834
1079
  if (
835
- !upstreamEnded &&
1080
+ (!upstreamEnded || refirePending) &&
836
1081
  !res.writableEnded &&
837
1082
  !nodeStream.destroyed
838
1083
  ) {
@@ -842,7 +1087,7 @@ export function pipeUpstreamStream(
842
1087
  });
843
1088
 
844
1089
  res.on("error", () => {
845
- if (!upstreamEnded && !nodeStream.destroyed) {
1090
+ if ((!upstreamEnded || refirePending) && !nodeStream.destroyed) {
846
1091
  clientAborted = true;
847
1092
  nodeStream.destroy();
848
1093
  }