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 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.0",
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
- * Wire subset of HealthData fetched from a running daemon over loopback.
51
- * Single source of truth for the recovery-decision shape (client.ts
52
- * HealthForRecovery) and the fetch shape (proxy.ts getDaemonHealth): each
53
- * field's type follows HealthData, so a rename there breaks here at compile
54
- * time instead of silently desyncing a hand-duplicated copy.
55
- * Fields stay optional — older daemons predate them — and version is nullable
56
- * for unversioned responses. The key list must match what getDaemonHealth parses.
57
- */
58
- export type DaemonHealthSnapshot = {
59
- [K in "activeRequests" | "sseRate" | "sseDegraded" | "lastBytesAt"]?: HealthData[K] | undefined;
60
- } & {
61
- version: string | null;
62
- } | null;
63
-
64
- /**
65
- * Collect current health snapshot.
66
- * @param portOverride - actual listening port (defaults to config PORT)
67
- * @param activeRequests - in-flight proxied requests (defaults to 0 for callers that do not track)
68
- */
69
- export function getHealthData(portOverride?: number, activeRequests = 0): HealthData {
70
- const state = getActiveRelayState();
71
- const relays: HealthRelayInfo[] = (state.relays || []).map((r) => {
72
- const h = getRelayHealth(r.url);
73
- const healthy = isRelayHealthy(r.url);
74
- return {
75
- url: r.url,
76
- label: r.label,
77
- healthy,
78
- cooldownUntil: h?.cooldownUntil ?? 0,
79
- consecutiveFailures: h?.consecutiveFailures ?? 0,
80
- };
81
- });
82
- const sse = getSseStats();
83
- return {
84
- port: portOverride ?? PORT,
85
- active: state.url || "",
86
- mode: (state.mode as string) ?? "auto",
87
- enabled: Boolean(state.enabled),
88
- relays,
89
- catalog: ALL_MODELS.length,
90
- version: PKG_VERSION,
91
- activeRequests,
92
- clients: getLeaseCount(),
93
- leases: getLeaseSnapshot(),
94
- lastActivityAt: getLastActivityAt(),
95
- sseFailed: sse.failures,
96
- sseTotal: sse.total,
97
- sseRate: sse.rate,
98
- sseDegraded: sse.degraded,
99
- lastBytesAt: getLastForwardedByteAt(),
100
- };
101
- }
102
-
103
- /**
104
- * Check whether an IP address is a loopback address (127.0.0.1, ::1, localhost).
105
- */
106
- export function isLoopbackIP(ip: string): boolean {
107
- if (!ip) return false;
108
- const withoutZone = ip.split("%")[0];
109
- const clean = withoutZone.startsWith("::ffff:") ? withoutZone.slice(7) : withoutZone;
110
- return clean === "127.0.0.1" || clean === "::1" || clean === "localhost";
111
- }
112
-
113
- /**
114
- * Handle loopback health requests.
115
- * Returns true if request was a health endpoint (handled, response already sent).
116
- * Returns false if not a health path (caller should continue).
117
- */
118
- export function handleHealthRequest(
119
- req: http.IncomingMessage,
120
- res: http.ServerResponse,
121
- portOverride?: number,
122
- activeRequests = 0,
123
- ): boolean {
124
- if (req.method !== "GET") return false;
125
-
126
- let reqPathname: string | null = null;
127
- try {
128
- reqPathname = new URL(req.url ?? "/", `http://127.0.0.1`).pathname;
129
- } catch {
130
- return false;
131
- }
132
- if (reqPathname === null) return false;
133
- if (reqPathname !== "/_health" && reqPathname !== "/health") return false;
134
-
135
- const clientIP = req.socket.remoteAddress ?? "";
136
- if (!isLoopbackIP(clientIP)) {
137
- res.writeHead(403, { "content-type": "application/json" });
138
- res.end(JSON.stringify({ error: "loopback only" }));
139
- return true;
140
- }
141
-
142
- const data = getHealthData(portOverride, activeRequests);
143
- const body = JSON.stringify(data, null, 2);
144
- res.writeHead(200, {
145
- "content-type": "application/json",
146
- "content-length": Buffer.byteLength(body),
147
- "cache-control": "no-store",
148
- });
149
- res.end(body);
150
- return true;
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),