pi-freeflow 1.40.0 → 1.40.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/src/relay.ts +54 -11
- package/src/stream-pipe.ts +57 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.40.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Long streams are no longer cut at the five-minute ceiling mid-turn; a stalled relay is cut and re-fired instead.
|
|
8
|
+
|
|
9
|
+
Relay attempts carried a single deadline for the whole request. When a heavy reasoning turn ran past five minutes — big-pickle and long deepseek sessions routinely do — the deadline fired on live bytes, the proxy saw the dead stream as an upstream truncation, and silently re-fired the turn on another relay: on the screen the answer suddenly stopped and then continued as a fresh full generation, with the earlier partial content above it. The deadline now bounds only the header wait and is released the moment headers arrive, so a streaming body lives as long as bytes keep flowing — exactly like the other proxy paths that already cleared their timer on headers.
|
|
10
|
+
|
|
11
|
+
A body that stops flowing entirely is cut by a per-turn idle watchdog (120s of byte silence on a live socket is a wedged tunnel, not a thinking pause) and follows the existing one-refire truncation path, so a hung relay leg still hands the turn to a sibling instead of stalling until the host gives up.
|
|
12
|
+
|
|
3
13
|
## 1.40.0
|
|
4
14
|
|
|
5
15
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.40.
|
|
4
|
+
"version": "1.40.1",
|
|
5
5
|
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
6
|
"main": "extensions/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
package/src/relay.ts
CHANGED
|
@@ -31,8 +31,8 @@ const ROLL_NOTIFY_MS = 5 * 60 * 1_000;
|
|
|
31
31
|
* per attempt. Relay leg only - the direct-to-upstream
|
|
32
32
|
* fallback keeps the built-in global dispatcher. headersTimeout
|
|
33
33
|
* reaps wedged tunnels, bodyTimeout 0 disables per-chunk kill
|
|
34
|
-
* (
|
|
35
|
-
* caps burst spray.
|
|
34
|
+
* (bodies stream unbounded — a wedged leg is cut by stream-pipe's
|
|
35
|
+
* body-stall watchdog instead), connections 50 caps burst spray.
|
|
36
36
|
*
|
|
37
37
|
* Compat: an npm undici Agent is rejected as a `dispatcher`
|
|
38
38
|
* by an older-major global fetch bundled in Node
|
|
@@ -75,6 +75,18 @@ export function _setHedgeGraceForTest(ms: number): () => void {
|
|
|
75
75
|
}
|
|
76
76
|
/** Test-only: reset roll-notify throttle */
|
|
77
77
|
export function _resetRollNotifyForTest(): void { lastRollNotify = 0; }
|
|
78
|
+
/********************************************************
|
|
79
|
+
* Relay attempt header-wait deadline (config default).
|
|
80
|
+
* Bounded only to the header wait; the streaming body must never be
|
|
81
|
+
* killed by a fixed wall-clock ceiling (see buildAttempt).
|
|
82
|
+
********************************************************/
|
|
83
|
+
let attemptHeaderTimeoutMs = UPSTREAM_HEADER_TIMEOUT_MS;
|
|
84
|
+
/** Test-only: override the relay attempt header deadline; returns a restore function. */
|
|
85
|
+
export function _setAttemptHeaderTimeoutForTest(ms: number): () => void {
|
|
86
|
+
const prev = attemptHeaderTimeoutMs;
|
|
87
|
+
attemptHeaderTimeoutMs = ms;
|
|
88
|
+
return () => { attemptHeaderTimeoutMs = prev; };
|
|
89
|
+
}
|
|
78
90
|
|
|
79
91
|
/**
|
|
80
92
|
* Determine if an HTTP status code indicates a temporary relay or upstream error
|
|
@@ -263,12 +275,28 @@ export async function relayFetch(
|
|
|
263
275
|
// window, combined with the caller's signal so either can fire.
|
|
264
276
|
// A hung relay then trips only its own timeout (roll) instead of
|
|
265
277
|
// vetoing the pool; a genuine client cancel (caller signal
|
|
266
|
-
// aborted) still propagates to both hedged attempts.
|
|
267
|
-
|
|
278
|
+
// aborted) still propagates to both hedged attempts. The timer
|
|
279
|
+
// is disarmed the moment the fetch settles: the deadline bounds
|
|
280
|
+
// the header WAIT only, never the streaming body (an
|
|
281
|
+
// AbortSignal.timeout would keep hanging over the body and kill
|
|
282
|
+
// a healthy turn past five minutes mid-generation).
|
|
283
|
+
const headerDeadlineCtl = new AbortController();
|
|
284
|
+
let headerTimer: NodeJS.Timeout | undefined;
|
|
285
|
+
let headerDeadlineDisarmed = false;
|
|
286
|
+
const disarmHeaderTimeout = (): void => {
|
|
287
|
+
if (headerDeadlineDisarmed) return;
|
|
288
|
+
headerDeadlineDisarmed = true;
|
|
289
|
+
clearTimeout(headerTimer);
|
|
290
|
+
headerTimer = undefined;
|
|
291
|
+
};
|
|
292
|
+
headerTimer = setTimeout(() => {
|
|
293
|
+
disarmHeaderTimeout();
|
|
294
|
+
headerDeadlineCtl.abort();
|
|
295
|
+
}, attemptHeaderTimeoutMs);
|
|
268
296
|
const attemptSignal = opts.signal
|
|
269
|
-
? AbortSignal.any([opts.signal,
|
|
270
|
-
: AbortSignal.any([
|
|
271
|
-
return { headers: attemptHeaders, signal: attemptSignal };
|
|
297
|
+
? AbortSignal.any([opts.signal, headerDeadlineCtl.signal, hedgeCtl.signal])
|
|
298
|
+
: AbortSignal.any([headerDeadlineCtl.signal, hedgeCtl.signal]);
|
|
299
|
+
return { headers: attemptHeaders, signal: attemptSignal, disarmHeaderTimeout };
|
|
272
300
|
};
|
|
273
301
|
|
|
274
302
|
// Hedged failover: an attempt with no response headers within
|
|
@@ -289,14 +317,26 @@ export async function relayFetch(
|
|
|
289
317
|
if (!canHedge) {
|
|
290
318
|
const soloCtl = new AbortController();
|
|
291
319
|
const solo = buildAttempt(targetUrl, soloCtl);
|
|
292
|
-
|
|
320
|
+
try {
|
|
321
|
+
res = await fetch(targetUrl, { ...opts, headers: solo.headers, signal: solo.signal, ...relayLegInit() } as unknown as RequestInit);
|
|
322
|
+
} finally {
|
|
323
|
+
solo.disarmHeaderTimeout();
|
|
324
|
+
}
|
|
293
325
|
} else {
|
|
294
326
|
const primaryUrl = targetUrl;
|
|
295
327
|
const nextUrl = candidates[i + 1];
|
|
296
328
|
const nextCheck = validateRelayUrl(nextUrl);
|
|
297
329
|
const hedgeCtlA = new AbortController();
|
|
298
330
|
const first = buildAttempt(primaryUrl, hedgeCtlA);
|
|
299
|
-
|
|
331
|
+
// Disarm a settled leg's header deadline: once the fetch resolves
|
|
332
|
+
// with headers (or rejects) the attempt's abort window is closed,
|
|
333
|
+
// so a streaming body is never killed past the fixed ceiling.
|
|
334
|
+
// Wrapping here covers both the staged promise reader and the
|
|
335
|
+
// hedge race below.
|
|
336
|
+
const fetchA = fetch(primaryUrl, { ...opts, headers: first.headers, signal: first.signal, ...relayLegInit() } as unknown as RequestInit).then(
|
|
337
|
+
(r: Response) => { first.disarmHeaderTimeout(); return r; },
|
|
338
|
+
(e: unknown) => { first.disarmHeaderTimeout(); throw e; },
|
|
339
|
+
);
|
|
300
340
|
const stalled = await new Promise<boolean>((resolve) => {
|
|
301
341
|
const graceTimer = setTimeout(() => resolve(true), HEDGE_GRACE_MS);
|
|
302
342
|
fetchA.then(
|
|
@@ -311,7 +351,10 @@ export async function relayFetch(
|
|
|
311
351
|
const hedgeCtlB = new AbortController();
|
|
312
352
|
const second = buildAttempt(nextUrl, hedgeCtlB);
|
|
313
353
|
const hedgeStart = Date.now();
|
|
314
|
-
const fetchB = fetch(nextUrl, { ...opts, headers: second.headers, signal: second.signal, ...relayLegInit() } as unknown as RequestInit)
|
|
354
|
+
const fetchB = fetch(nextUrl, { ...opts, headers: second.headers, signal: second.signal, ...relayLegInit() } as unknown as RequestInit).then(
|
|
355
|
+
(r: Response) => { second.disarmHeaderTimeout(); return r; },
|
|
356
|
+
(e: unknown) => { second.disarmHeaderTimeout(); throw e; },
|
|
357
|
+
);
|
|
315
358
|
// Swallow the loser path so the deliberate abort never
|
|
316
359
|
// surfaces as an unhandled rejection or a direct fallback.
|
|
317
360
|
fetchA.catch(() => {});
|
|
@@ -671,7 +714,7 @@ export async function relayFetch(
|
|
|
671
714
|
rateLimitOnly = false;
|
|
672
715
|
const errMsg =
|
|
673
716
|
(err as Error)?.name === "AbortError"
|
|
674
|
-
? `relay header timeout (${Math.round(
|
|
717
|
+
? `relay header timeout (${Math.round(attemptHeaderTimeoutMs / 1000)}s exceeded)`
|
|
675
718
|
: (err as Error)?.message || String(err);
|
|
676
719
|
markRelayFailure(targetUrl, 0, errMsg);
|
|
677
720
|
log(
|
package/src/stream-pipe.ts
CHANGED
|
@@ -27,6 +27,17 @@ import { repairToolArguments } from "./tool-args.ts";
|
|
|
27
27
|
*/
|
|
28
28
|
export const SUBSTANTIAL_MIN_CHUNKS = 50;
|
|
29
29
|
export const SUBSTANTIAL_MIN_BYTES = 100 * 1024;
|
|
30
|
+
// Body-stall cut for streamed upstream bodies (see pipeUpstreamStream).
|
|
31
|
+
// Generous: every healthy stream observed on this proxy re-arms it within a
|
|
32
|
+
// few hundred ms; a 120s byte-silence on an open socket is a wedged relay
|
|
33
|
+
// leg, not a building model.
|
|
34
|
+
let bodyStallCutMs = 120_000;
|
|
35
|
+
/** Test-only: override the body-stall cut; returns a restore function. */
|
|
36
|
+
export function _setBodyStallCutForTest(ms: number): () => void {
|
|
37
|
+
const prev = bodyStallCutMs;
|
|
38
|
+
bodyStallCutMs = ms;
|
|
39
|
+
return () => { bodyStallCutMs = prev; };
|
|
40
|
+
}
|
|
30
41
|
// Bounded lookahead for a streaming response whose upstream omitted the
|
|
31
42
|
// content-type: the pipe holds at most this many bytes while it waits for SSE
|
|
32
43
|
// evidence, then classifies the body itself (raw passthrough on overflow —
|
|
@@ -993,6 +1004,46 @@ export function pipeUpstreamStream(
|
|
|
993
1004
|
// One-shot resume guard: the refire hook below fires at most once per turn.
|
|
994
1005
|
let refireUsed = false;
|
|
995
1006
|
|
|
1007
|
+
// Body-stall watchdog: after response headers a streaming body has no fixed
|
|
1008
|
+
// total deadline (See relay.ts: the per-attempt timer is headers-only).
|
|
1009
|
+
// The only failure left is a wedged leg that keeps the socket open
|
|
1010
|
+
// and sends nothing — protected by a per-stream idle cut well above every
|
|
1011
|
+
// observed healthy inter-chunk gap (thinking-heavy streams emit sub-second,
|
|
1012
|
+
// even the slowest logged TTFB is tens of seconds). On expiry the CURRENT
|
|
1013
|
+
// stream is destroyed with a relay-fault error so the truncation path
|
|
1014
|
+
// (health mark + one mid-stream refire) owns the turn. Every forwarded byte
|
|
1015
|
+
// re-arms it; end/close/error disarm it; a rescue re-arms the replacement.
|
|
1016
|
+
let stallCutTimer: NodeJS.Timeout | undefined;
|
|
1017
|
+
let stallCutStream: Readable | null = null;
|
|
1018
|
+
const fireStallCut = (target: Readable): void => {
|
|
1019
|
+
stallCutTimer = undefined;
|
|
1020
|
+
stallCutStream = null;
|
|
1021
|
+
if (target.destroyed || clientAborted || target !== nodeStream) return;
|
|
1022
|
+
const stallErr = new Error(
|
|
1023
|
+
`upstream body idle (no data for ${bodyStallCutMs}ms)`,
|
|
1024
|
+
) as Error & { code?: string };
|
|
1025
|
+
stallErr.code = "FF_BODY_STALL";
|
|
1026
|
+
stallErr.name = "TimeoutError";
|
|
1027
|
+
target.destroy(stallErr);
|
|
1028
|
+
};
|
|
1029
|
+
const scheduleStallCut = (s: Readable): void => {
|
|
1030
|
+
clearTimeout(stallCutTimer);
|
|
1031
|
+
stallCutStream = s;
|
|
1032
|
+
const timer = setTimeout(() => fireStallCut(s), bodyStallCutMs);
|
|
1033
|
+
timer.unref?.();
|
|
1034
|
+
stallCutTimer = timer;
|
|
1035
|
+
};
|
|
1036
|
+
const breatheStallCut = (): void => {
|
|
1037
|
+
if (stallCutTimer !== undefined && stallCutStream === nodeStream) {
|
|
1038
|
+
scheduleStallCut(nodeStream);
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
const disarmStallCut = (): void => {
|
|
1042
|
+
clearTimeout(stallCutTimer);
|
|
1043
|
+
stallCutTimer = undefined;
|
|
1044
|
+
stallCutStream = null;
|
|
1045
|
+
};
|
|
1046
|
+
|
|
996
1047
|
const sniffThinking = (chunk: Buffer | string): boolean => {
|
|
997
1048
|
const s =
|
|
998
1049
|
typeof chunk === "string"
|
|
@@ -1287,6 +1338,7 @@ export function pipeUpstreamStream(
|
|
|
1287
1338
|
sseDecoder = new StringDecoder("utf8");
|
|
1288
1339
|
terminalScanCarry = Buffer.alloc(0);
|
|
1289
1340
|
watch(next);
|
|
1341
|
+
scheduleStallCut(next);
|
|
1290
1342
|
log("debug", "mid-stream refire: continuing turn on replacement stream", { totalChunks, totalBytes }, rid);
|
|
1291
1343
|
return true;
|
|
1292
1344
|
};
|
|
@@ -1341,6 +1393,7 @@ export function pipeUpstreamStream(
|
|
|
1341
1393
|
};
|
|
1342
1394
|
const onData = (chunk: Buffer | string): void => {
|
|
1343
1395
|
try {
|
|
1396
|
+
breatheStallCut();
|
|
1344
1397
|
// While a replacement stream is being fetched the cut stream is stale:
|
|
1345
1398
|
// its late frames must not interleave with the rescued turn.
|
|
1346
1399
|
if (refirePending) return;
|
|
@@ -1386,6 +1439,7 @@ export function pipeUpstreamStream(
|
|
|
1386
1439
|
};
|
|
1387
1440
|
|
|
1388
1441
|
const onError = async (e: unknown): Promise<void> => {
|
|
1442
|
+
disarmStallCut();
|
|
1389
1443
|
// A rescue is already in flight (its outcome decides the turn): a late
|
|
1390
1444
|
// error from the cut stream must not inject a terminal mid-rescue.
|
|
1391
1445
|
if (refirePending) return;
|
|
@@ -1437,6 +1491,7 @@ export function pipeUpstreamStream(
|
|
|
1437
1491
|
};
|
|
1438
1492
|
|
|
1439
1493
|
const onEnd = async (): Promise<void> => {
|
|
1494
|
+
disarmStallCut();
|
|
1440
1495
|
// A rescue is in flight (its outcome owns the turn): the cut stream's FIN
|
|
1441
1496
|
// must neither inject a terminal nor end res mid-rescue.
|
|
1442
1497
|
if (refirePending) return;
|
|
@@ -1542,6 +1597,7 @@ export function pipeUpstreamStream(
|
|
|
1542
1597
|
};
|
|
1543
1598
|
|
|
1544
1599
|
const onClose = async (): Promise<void> => {
|
|
1600
|
+
disarmStallCut();
|
|
1545
1601
|
// A stalled `close` trailing an `end` that is mid-rescue must neither
|
|
1546
1602
|
// inject a terminal nor end res — the replacement stream owns the turn now.
|
|
1547
1603
|
if (refirePending) return;
|
|
@@ -1603,6 +1659,7 @@ export function pipeUpstreamStream(
|
|
|
1603
1659
|
s.on("close", () => { if (s === nodeStream) void onClose(); });
|
|
1604
1660
|
};
|
|
1605
1661
|
watch(nodeStream);
|
|
1662
|
+
scheduleStallCut(nodeStream);
|
|
1606
1663
|
|
|
1607
1664
|
req.on("aborted", () => {
|
|
1608
1665
|
clientAborted = true;
|