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/CHANGELOG.md +14 -0
- package/package.json +4 -2
- package/src/health.ts +157 -150
- package/src/proxy.ts +64 -1
- package/src/relay-state.ts +889 -868
- package/src/relay.ts +61 -3
- package/src/stream-pipe.ts +307 -62
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):
|
package/src/stream-pipe.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
921
|
+
} else {
|
|
922
|
+
forwardText(`${block}\n\n`);
|
|
685
923
|
}
|
|
686
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
977
|
+
if (sseBuffer.trim() !== "") {
|
|
763
978
|
const tail = sseBuffer;
|
|
764
979
|
sseBuffer = "";
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
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
|
}
|