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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.32.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Prevent JSON parse errors on interrupted responses: network packet boundaries split mid-delta no longer produce unterminated string syntax errors when a turn is retried or terminated.
|
|
8
|
+
|
|
9
|
+
## 1.32.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Recover interrupted answers automatically: when a response stream cuts off mid-answer, the pending turn is retried once on the next healthy relay instead of stopping partway
|
|
14
|
+
- Retry once automatically when the model briefly fails a request: a transient model-side error is now retried on the next relay instead of surfacing immediately, and health reporting distinguishes model failures from relay problems
|
|
15
|
+
- 9b523e9: Keep relay connections warm across turns for faster repeat requests
|
|
16
|
+
|
|
3
17
|
## 1.32.0
|
|
4
18
|
|
|
5
19
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.32.
|
|
4
|
+
"version": "1.32.2",
|
|
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",
|
|
@@ -57,5 +57,7 @@
|
|
|
57
57
|
"typescript": "^5.8.2",
|
|
58
58
|
"vitepress": "^1.6.4"
|
|
59
59
|
},
|
|
60
|
-
"dependencies": {
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"undici": "7.30.0"
|
|
62
|
+
}
|
|
61
63
|
}
|
package/src/health.ts
CHANGED
|
@@ -1,151 +1,158 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Health endpoint for pi-freeflow proxy
|
|
3
|
-
* Loopback-only GET /_health (and alias /health) returning relay health snapshot.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type * as http from "node:http";
|
|
7
|
-
import { ALL_MODELS } from "./models.ts";
|
|
8
|
-
import { getActiveRelayState, getRelayHealth, isRelayHealthy } from "./relay-state.ts";
|
|
9
|
-
import { getLastActivityAt, getLeaseCount, getLeaseSnapshot } from "./lease.ts";
|
|
10
|
-
import { PKG_VERSION, PORT } from "./config.ts";
|
|
11
|
-
import { getLastForwardedByteAt, getSseStats } from "./stream-pipe.ts";
|
|
12
|
-
|
|
13
|
-
export interface HealthRelayInfo {
|
|
14
|
-
url: string;
|
|
15
|
-
label?: string;
|
|
16
|
-
healthy: boolean;
|
|
17
|
-
cooldownUntil: number;
|
|
18
|
-
consecutiveFailures: number;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface HealthData {
|
|
22
|
-
port: number;
|
|
23
|
-
active: string;
|
|
24
|
-
mode: string;
|
|
25
|
-
enabled: boolean;
|
|
26
|
-
relays: HealthRelayInfo[];
|
|
27
|
-
catalog: number;
|
|
28
|
-
version: string;
|
|
29
|
-
/** In-flight proxied requests right now (stale-daemon replacement guard). */
|
|
30
|
-
activeRequests: number;
|
|
31
|
-
/** Clients holding a live lease (detached-daemon GC). */
|
|
32
|
-
clients: number;
|
|
33
|
-
/** clientId -> lastSeenAt for every live lease. */
|
|
34
|
-
leases: Record<string, number>;
|
|
35
|
-
/** Last time any request was proxied (request-touch for legacy clients). */
|
|
36
|
-
lastActivityAt: number;
|
|
37
|
-
/** Failed-SSE rolling window: failures in the last 20 streams. */
|
|
38
|
-
sseFailed: number;
|
|
39
|
-
/** Streams recorded in the current rolling window (max 20). */
|
|
40
|
-
sseTotal: number;
|
|
41
|
-
/** Failure rate over the window (0 when empty). */
|
|
42
|
-
sseRate: number;
|
|
43
|
-
/** True when the window holds enough samples and the rate exceeds 50%. */
|
|
44
|
-
sseDegraded: boolean;
|
|
45
|
-
/** Last time any stream byte was forwarded (busy-bypass quiet check), 0 when no stream yet. */
|
|
46
|
-
lastBytesAt: number;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Health endpoint for pi-freeflow proxy
|
|
3
|
+
* Loopback-only GET /_health (and alias /health) returning relay health snapshot.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type * as http from "node:http";
|
|
7
|
+
import { ALL_MODELS } from "./models.ts";
|
|
8
|
+
import { getActiveRelayState, getRelayHealth, isRelayHealthy } from "./relay-state.ts";
|
|
9
|
+
import { getLastActivityAt, getLeaseCount, getLeaseSnapshot } from "./lease.ts";
|
|
10
|
+
import { PKG_VERSION, PORT } from "./config.ts";
|
|
11
|
+
import { getLastForwardedByteAt, getMidStreamFaultStats, getSseStats } from "./stream-pipe.ts";
|
|
12
|
+
|
|
13
|
+
export interface HealthRelayInfo {
|
|
14
|
+
url: string;
|
|
15
|
+
label?: string;
|
|
16
|
+
healthy: boolean;
|
|
17
|
+
cooldownUntil: number;
|
|
18
|
+
consecutiveFailures: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface HealthData {
|
|
22
|
+
port: number;
|
|
23
|
+
active: string;
|
|
24
|
+
mode: string;
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
relays: HealthRelayInfo[];
|
|
27
|
+
catalog: number;
|
|
28
|
+
version: string;
|
|
29
|
+
/** In-flight proxied requests right now (stale-daemon replacement guard). */
|
|
30
|
+
activeRequests: number;
|
|
31
|
+
/** Clients holding a live lease (detached-daemon GC). */
|
|
32
|
+
clients: number;
|
|
33
|
+
/** clientId -> lastSeenAt for every live lease. */
|
|
34
|
+
leases: Record<string, number>;
|
|
35
|
+
/** Last time any request was proxied (request-touch for legacy clients). */
|
|
36
|
+
lastActivityAt: number;
|
|
37
|
+
/** Failed-SSE rolling window: failures in the last 20 streams. */
|
|
38
|
+
sseFailed: number;
|
|
39
|
+
/** Streams recorded in the current rolling window (max 20). */
|
|
40
|
+
sseTotal: number;
|
|
41
|
+
/** Failure rate over the window (0 when empty). */
|
|
42
|
+
sseRate: number;
|
|
43
|
+
/** True when the window holds enough samples and the rate exceeds 50%. */
|
|
44
|
+
sseDegraded: boolean;
|
|
45
|
+
/** Last time any stream byte was forwarded (busy-bypass quiet check), 0 when no stream yet. */
|
|
46
|
+
lastBytesAt: number;
|
|
47
|
+
/** Mid-stream turns the upstream model itself failed (model fault, never a relay fault). */
|
|
48
|
+
midStreamModelFaults: number;
|
|
49
|
+
/** Mid-stream turns cut by the relay/transport (relay fault, rotated away from). */
|
|
50
|
+
midStreamRelayTruncations: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wire subset of HealthData fetched from a running daemon over loopback.
|
|
55
|
+
* Single source of truth for the recovery-decision shape (client.ts
|
|
56
|
+
* HealthForRecovery) and the fetch shape (proxy.ts getDaemonHealth): each
|
|
57
|
+
* field's type follows HealthData, so a rename there breaks here at compile
|
|
58
|
+
* time instead of silently desyncing a hand-duplicated copy.
|
|
59
|
+
* Fields stay optional — older daemons predate them — and version is nullable
|
|
60
|
+
* for unversioned responses. The key list must match what getDaemonHealth parses.
|
|
61
|
+
*/
|
|
62
|
+
export type DaemonHealthSnapshot = {
|
|
63
|
+
[K in "activeRequests" | "sseRate" | "sseDegraded" | "lastBytesAt"]?: HealthData[K] | undefined;
|
|
64
|
+
} & {
|
|
65
|
+
version: string | null;
|
|
66
|
+
} | null;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Collect current health snapshot.
|
|
70
|
+
* @param portOverride - actual listening port (defaults to config PORT)
|
|
71
|
+
* @param activeRequests - in-flight proxied requests (defaults to 0 for callers that do not track)
|
|
72
|
+
*/
|
|
73
|
+
export function getHealthData(portOverride?: number, activeRequests = 0): HealthData {
|
|
74
|
+
const state = getActiveRelayState();
|
|
75
|
+
const relays: HealthRelayInfo[] = (state.relays || []).map((r) => {
|
|
76
|
+
const h = getRelayHealth(r.url);
|
|
77
|
+
const healthy = isRelayHealthy(r.url);
|
|
78
|
+
return {
|
|
79
|
+
url: r.url,
|
|
80
|
+
label: r.label,
|
|
81
|
+
healthy,
|
|
82
|
+
cooldownUntil: h?.cooldownUntil ?? 0,
|
|
83
|
+
consecutiveFailures: h?.consecutiveFailures ?? 0,
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
const sse = getSseStats();
|
|
87
|
+
const midStreamFaults = getMidStreamFaultStats();
|
|
88
|
+
return {
|
|
89
|
+
port: portOverride ?? PORT,
|
|
90
|
+
active: state.url || "",
|
|
91
|
+
mode: (state.mode as string) ?? "auto",
|
|
92
|
+
enabled: Boolean(state.enabled),
|
|
93
|
+
relays,
|
|
94
|
+
catalog: ALL_MODELS.length,
|
|
95
|
+
version: PKG_VERSION,
|
|
96
|
+
activeRequests,
|
|
97
|
+
clients: getLeaseCount(),
|
|
98
|
+
leases: getLeaseSnapshot(),
|
|
99
|
+
lastActivityAt: getLastActivityAt(),
|
|
100
|
+
sseFailed: sse.failures,
|
|
101
|
+
sseTotal: sse.total,
|
|
102
|
+
sseRate: sse.rate,
|
|
103
|
+
sseDegraded: sse.degraded,
|
|
104
|
+
lastBytesAt: getLastForwardedByteAt(),
|
|
105
|
+
midStreamModelFaults: midStreamFaults.modelFaults,
|
|
106
|
+
midStreamRelayTruncations: midStreamFaults.relayTruncations,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check whether an IP address is a loopback address (127.0.0.1, ::1, localhost).
|
|
112
|
+
*/
|
|
113
|
+
export function isLoopbackIP(ip: string): boolean {
|
|
114
|
+
if (!ip) return false;
|
|
115
|
+
const withoutZone = ip.split("%")[0];
|
|
116
|
+
const clean = withoutZone.startsWith("::ffff:") ? withoutZone.slice(7) : withoutZone;
|
|
117
|
+
return clean === "127.0.0.1" || clean === "::1" || clean === "localhost";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Handle loopback health requests.
|
|
122
|
+
* Returns true if request was a health endpoint (handled, response already sent).
|
|
123
|
+
* Returns false if not a health path (caller should continue).
|
|
124
|
+
*/
|
|
125
|
+
export function handleHealthRequest(
|
|
126
|
+
req: http.IncomingMessage,
|
|
127
|
+
res: http.ServerResponse,
|
|
128
|
+
portOverride?: number,
|
|
129
|
+
activeRequests = 0,
|
|
130
|
+
): boolean {
|
|
131
|
+
if (req.method !== "GET") return false;
|
|
132
|
+
|
|
133
|
+
let reqPathname: string | null = null;
|
|
134
|
+
try {
|
|
135
|
+
reqPathname = new URL(req.url ?? "/", `http://127.0.0.1`).pathname;
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
if (reqPathname === null) return false;
|
|
140
|
+
if (reqPathname !== "/_health" && reqPathname !== "/health") return false;
|
|
141
|
+
|
|
142
|
+
const clientIP = req.socket.remoteAddress ?? "";
|
|
143
|
+
if (!isLoopbackIP(clientIP)) {
|
|
144
|
+
res.writeHead(403, { "content-type": "application/json" });
|
|
145
|
+
res.end(JSON.stringify({ error: "loopback only" }));
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const data = getHealthData(portOverride, activeRequests);
|
|
150
|
+
const body = JSON.stringify(data, null, 2);
|
|
151
|
+
res.writeHead(200, {
|
|
152
|
+
"content-type": "application/json",
|
|
153
|
+
"content-length": Buffer.byteLength(body),
|
|
154
|
+
"cache-control": "no-store",
|
|
155
|
+
});
|
|
156
|
+
res.end(body);
|
|
157
|
+
return true;
|
|
151
158
|
}
|
package/src/proxy.ts
CHANGED
|
@@ -53,7 +53,7 @@ import {
|
|
|
53
53
|
} from "./tool-translation.ts";
|
|
54
54
|
// normalize removed — host pi-ai already normalizes thinking/reasoning before proxy
|
|
55
55
|
import { relayFetch } from "./relay.ts";
|
|
56
|
-
import { getActiveRelayState, orderedRelayCandidates } from "./relay-state.ts";
|
|
56
|
+
import { getActiveRelayState, markRelayFailure, orderedRelayCandidates } from "./relay-state.ts";
|
|
57
57
|
import {
|
|
58
58
|
issuerRelayFor,
|
|
59
59
|
prepareChatFailoverBody,
|
|
@@ -1376,6 +1376,17 @@ export function startProxy(
|
|
|
1376
1376
|
connection: "keep-alive",
|
|
1377
1377
|
"x-accel-buffering": "no",
|
|
1378
1378
|
});
|
|
1379
|
+
// Mid-stream failover (budget: exactly one re-fire): headers are
|
|
1380
|
+
// already sent, so a relay that FINs the SSE body without a terminal
|
|
1381
|
+
// marker cannot roll the usual pre-stream way. pipeUpstreamStream
|
|
1382
|
+
// invokes refire at most once, only for genuine upstream truncation
|
|
1383
|
+
// with the client still attached — client aborts and clean terminals
|
|
1384
|
+
// never re-fire. Public practice matches: gateways retry the request
|
|
1385
|
+
// (LiteLLM router fallbacks); SSE resume is not a thing (the API has
|
|
1386
|
+
// no range semantics). The host abandons partial tool calls on
|
|
1387
|
+
// incomplete turns, so the re-fired turn yields a fresh complete
|
|
1388
|
+
// answer instead of a dead stop.
|
|
1389
|
+
let midStreamRefires = 0;
|
|
1379
1390
|
pipeUpstreamStream(
|
|
1380
1391
|
Readable.fromWeb(
|
|
1381
1392
|
response.body as unknown as WebReadableStream,
|
|
@@ -1385,6 +1396,54 @@ export function startProxy(
|
|
|
1385
1396
|
reqId,
|
|
1386
1397
|
servedIssuer ?? "direct",
|
|
1387
1398
|
streamCloak,
|
|
1399
|
+
{
|
|
1400
|
+
refire: async () => {
|
|
1401
|
+
if (midStreamRefires >= 1) return null;
|
|
1402
|
+
midStreamRefires += 1;
|
|
1403
|
+
const cutter = servedIssuer ?? "direct";
|
|
1404
|
+
log(
|
|
1405
|
+
"warn",
|
|
1406
|
+
`responses stream truncated on ${cutter === "direct" ? "direct upstream" : `relay ${cutter}`} — re-firing once on next relay`,
|
|
1407
|
+
{ model: (parsedBody as Record<string, unknown> | null)?.model, path: req.url, relay: cutter },
|
|
1408
|
+
reqId,
|
|
1409
|
+
);
|
|
1410
|
+
let retryRes: Response;
|
|
1411
|
+
// Mark the cutter BEFORE re-sending: candidate ordering partitions
|
|
1412
|
+
// healthy-first, so only a pre-marked (cooling) cutter guarantees
|
|
1413
|
+
// the re-fire rolls to a sibling instead of retrying the relay
|
|
1414
|
+
// that just dropped us. A mark here is deserved either way — the
|
|
1415
|
+
// relay demonstrably cut a live stream. (If the re-fire itself
|
|
1416
|
+
// finds nothing usable, the pipe fallthrough may add its own mark
|
|
1417
|
+
// on socket-error paths; bounded by the 4x escalation cap.)
|
|
1418
|
+
if (cutter !== "direct") markRelayFailure(cutter, 0, "stream truncated mid-stream");
|
|
1419
|
+
try {
|
|
1420
|
+
retryRes = await sendViaRelay(bodyForUpstream);
|
|
1421
|
+
if (responsesRequest) {
|
|
1422
|
+
retryRes = await retryWithoutReasoningEncryption(
|
|
1423
|
+
retryRes,
|
|
1424
|
+
requestBody,
|
|
1425
|
+
sendViaRelay,
|
|
1426
|
+
reqId,
|
|
1427
|
+
conversationKey,
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1430
|
+
} catch {
|
|
1431
|
+
return null;
|
|
1432
|
+
}
|
|
1433
|
+
if (!retryRes.ok || !retryRes.body) {
|
|
1434
|
+
try {
|
|
1435
|
+
await retryRes.body?.cancel();
|
|
1436
|
+
} catch { }
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1439
|
+
relayResponse = retryRes;
|
|
1440
|
+
recordUpstreamSuccess("zen", { sessionKey, canary: isCanary });
|
|
1441
|
+
if (responsesRequest && conversationKey !== null && retryRes.ok && issuerReported) {
|
|
1442
|
+
rememberIssuerRelay(conversationKey, servedIssuer);
|
|
1443
|
+
}
|
|
1444
|
+
return Readable.fromWeb(retryRes.body as unknown as WebReadableStream);
|
|
1445
|
+
},
|
|
1446
|
+
},
|
|
1388
1447
|
);
|
|
1389
1448
|
} else {
|
|
1390
1449
|
if (!response.ok) {
|
|
@@ -1572,6 +1631,10 @@ export function startProxy(
|
|
|
1572
1631
|
if (ka) outHeaders["keep-alive"] = ka;
|
|
1573
1632
|
|
|
1574
1633
|
if (isStream && upstreamRes.body) {
|
|
1634
|
+
// Force anti-buffer headers on the streaming leg: upstream rarely sends
|
|
1635
|
+
// them, and without them a downstream hop may buffer SSE chunks.
|
|
1636
|
+
outHeaders["cache-control"] = outHeaders["cache-control"] ?? "no-cache, no-transform";
|
|
1637
|
+
outHeaders["x-accel-buffering"] = "no";
|
|
1575
1638
|
res.writeHead(upstreamRes.status, outHeaders);
|
|
1576
1639
|
pipeUpstreamStream(
|
|
1577
1640
|
Readable.fromWeb(upstreamRes.body as unknown as WebReadableStream),
|