pi-freeflow 1.4.2 → 1.4.3

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.
@@ -1,263 +1,265 @@
1
- /**
2
- * Upstream SSE streaming pipeline with thinking sniffing and exception isolation.
3
- *
4
- * Prevents upstream stream aborts or timeouts from crashing the host process.
5
- * Sniffs thinking and reasoning chunks for debug trace logging without payload mutation.
6
- */
7
-
8
- import { randomUUID } from "node:crypto";
9
- import type * as http from "node:http";
10
- import type { Readable } from "node:stream";
11
- import { isDebugEnabled, log } from "./logger.ts";
12
- import { markRelayFailure } from "./relay-state.ts";
13
- /**
14
- * Pipes an upstream readable stream to a client HTTP response.
15
- *
16
- * - Flushes HTTP headers immediately for low Time-to-First-Byte (TTFB).
17
- * - Detects thinking/reasoning tokens in chunks for diagnostic logs.
18
- * - Handles upstream errors (returns 502 if headers unsent) and ends response.
19
- * - Cleans up resources and destroys upstream stream on client disconnect/abort.
20
- */
21
- export function pipeUpstreamStream(
22
- nodeStream: Readable,
23
- res: http.ServerResponse,
24
- req: http.IncomingMessage,
25
- reqId?: string,
26
- relayUrl?: string,
27
- ): void {
28
- const rid = reqId || randomUUID().slice(0, 8);
29
- let totalChunks = 0;
30
- let totalBytes = 0;
31
- let thinkingChunks = 0;
32
- let thinkingBytes = 0;
33
- let firstChunkAt: number | null = null;
34
- const startAt = Date.now();
35
- const isResponsesApi = req.url?.includes("/responses") ?? false;
36
- let hasTerminalEvent = false;
37
- // Abort-cause tracking: only genuine upstream-side truncation may penalize
38
- // relay health. Client disconnects and clean upstream ends must not.
39
- let upstreamEnded = false;
40
- let clientAborted = false;
41
- const TERMINAL_HEAD_SCAN_BYTES = 2000;
42
- const TERMINAL_TAIL_SCAN_BYTES = 2000;
43
-
44
- const sniffThinking = (chunk: Buffer | string): boolean => {
45
- const s =
46
- typeof chunk === "string"
47
- ? chunk
48
- : chunk.toString("utf8", 0, Math.min(chunk.length, 4000));
49
- return (
50
- s.includes("reasoning") ||
51
- s.includes("thinking") ||
52
- s.includes("<think>") ||
53
- s.includes("reasoning_content") ||
54
- s.includes('"type":"thinking"') ||
55
- s.includes("thinking_delta")
56
- );
57
- };
58
-
59
- const checkTerminalEvent = (s: string): boolean => {
60
- return (
61
- s.includes("response.completed") ||
62
- s.includes("response.done") ||
63
- s.includes("response.failed") ||
64
- s.includes("response.incomplete") ||
65
- s.includes("[DONE]")
66
- );
67
- };
68
- const ensureTerminalEvent = (
69
- isError = false,
70
- errorMsg?: string,
71
- penalizeRelay = true,
72
- ) => {
73
- if (hasTerminalEvent || res.writableEnded) return;
74
- // Only genuine upstream-side truncation penalizes relay health.
75
- // Client disconnects and clean upstream ends leave it untouched.
76
- if (penalizeRelay && relayUrl && relayUrl !== "direct") {
77
- markRelayFailure(relayUrl, 0, errorMsg || "stream truncated prematurely");
78
- }
79
- if (isResponsesApi && totalChunks > 0) {
80
- try {
81
- if (isError) {
82
- res.write(
83
- `\nevent: response.failed\ndata: {"type":"response.failed","response":{"status":"failed","error":{"code":"stream_error","message":${JSON.stringify(errorMsg || "Upstream stream disconnected unexpectedly")}}}}\n\n`,
84
- );
85
- } else {
86
- res.write(
87
- `\nevent: response.incomplete\ndata: {"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"cancelled"}}}\n\n`,
88
- );
89
- }
90
- hasTerminalEvent = true;
91
- log(
92
- "warn",
93
- `injected synthetic response.${isError ? "failed" : "incomplete"} for prematurely truncated stream`,
94
- { totalChunks, totalBytes, isError, errorMsg },
95
- rid,
96
- );
97
- } catch {}
98
- } else if (!isResponsesApi && totalChunks > 0) {
99
- try {
100
- res.write("\ndata: [DONE]\n\n");
101
- hasTerminalEvent = true;
102
- } catch {}
103
- }
104
- };
105
-
106
- try {
107
- if (typeof res.flushHeaders === "function") {
108
- res.flushHeaders();
109
- }
110
- } catch {}
111
- nodeStream.on("data", (chunk: Buffer | string) => {
112
- try {
113
- if (firstChunkAt === null) {
114
- firstChunkAt = Date.now();
115
- const ttfb = firstChunkAt - startAt;
116
- log("debug", `stream first chunk in ${ttfb}ms`, undefined, rid);
117
- }
118
- totalChunks++;
119
- const chunkSize =
120
- typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length;
121
- totalBytes += chunkSize;
122
-
123
- if (sniffThinking(chunk)) {
124
- thinkingChunks++;
125
- thinkingBytes += chunkSize;
126
- if (isDebugEnabled() && thinkingChunks <= 3) {
127
- const preview =
128
- typeof chunk === "string"
129
- ? chunk.slice(0, 600)
130
- : chunk.toString("utf8", 0, 600);
131
- log(
132
- "debug",
133
- `thinking chunk #${thinkingChunks}`,
134
- { preview: preview.slice(0, 400) },
135
- rid,
136
- );
137
- }
138
- }
139
- let strPreview: string;
140
- if (typeof chunk === "string") {
141
- strPreview = chunk;
142
- } else if (chunk.length <= TERMINAL_HEAD_SCAN_BYTES) {
143
- strPreview = chunk.toString("utf8");
144
- } else {
145
- // Coalesced final chunks can carry "[DONE]" well past the head —
146
- // scan the tail too so healthy completions are not penalized.
147
- strPreview = `${chunk.toString("utf8", 0, TERMINAL_HEAD_SCAN_BYTES)}\n${chunk.toString(
148
- "utf8",
149
- chunk.length - TERMINAL_TAIL_SCAN_BYTES,
150
- )}`;
151
- }
152
- if (!hasTerminalEvent && checkTerminalEvent(strPreview)) {
153
- hasTerminalEvent = true;
154
- }
155
-
156
- res.write(chunk);
157
- const maybeFlush = res as unknown as { flush?: () => void };
158
- if (typeof maybeFlush.flush === "function") {
159
- maybeFlush.flush();
160
- }
161
- } catch {}
162
- });
163
-
164
- nodeStream.on("error", (e: unknown) => {
165
- const errorMsg = (e as Error)?.message || String(e);
166
- log(
167
- "error",
168
- "upstream stream error",
169
- { error: errorMsg, totalChunks, thinkingChunks, hasTerminalEvent },
170
- rid,
171
- );
172
- try {
173
- if (!res.headersSent) {
174
- res.writeHead(502, { "content-type": "application/json" });
175
- } else {
176
- ensureTerminalEvent(true, errorMsg);
177
- }
178
- if (!res.writableEnded) {
179
- res.end();
180
- }
181
- } catch {}
182
- });
183
-
184
- nodeStream.on("end", () => {
185
- upstreamEnded = true;
186
- const elapsed = ((Date.now() - startAt) / 1000).toFixed(1);
187
- if (!hasTerminalEvent && totalChunks > 0) {
188
- // Clean upstream end without a detectable marker: keep the host
189
- // contract (synthetic terminal) but never blame the relay.
190
- ensureTerminalEvent(
191
- false,
192
- "upstream ended without terminal marker",
193
- false,
194
- );
195
- }
196
- if (thinkingChunks > 0) {
197
- log(
198
- "info",
199
- `stream ended in ${elapsed}s — ${totalChunks} chunks (${(totalBytes / 1024).toFixed(1)}KB), thinking: ${thinkingChunks} chunks (${(thinkingBytes / 1024).toFixed(1)}KB)`,
200
- undefined,
201
- rid,
202
- );
203
- } else if (isDebugEnabled()) {
204
- log(
205
- "debug",
206
- `stream ended in ${elapsed}s — ${totalChunks} chunks (${(totalBytes / 1024).toFixed(1)}KB), no thinking detected`,
207
- undefined,
208
- rid,
209
- );
210
- }
211
- try {
212
- if (!res.writableEnded) res.end();
213
- } catch {}
214
- });
215
-
216
- nodeStream.on("close", () => {
217
- try {
218
- if (!hasTerminalEvent && totalChunks > 0) {
219
- if (clientAborted || upstreamEnded) {
220
- // Client disconnect teardown or post-end cleanup — the relay
221
- // is not at fault; still give the host a terminal event.
222
- ensureTerminalEvent(false, "stream interrupted by client", false);
223
- } else {
224
- // Upstream socket died mid-stream with no error event:
225
- // genuine upstream truncation — penalize the relay.
226
- ensureTerminalEvent(true, "stream closed prematurely");
227
- }
228
- }
229
- if (!res.writableEnded) res.end();
230
- } catch {}
231
- });
232
-
233
- req.on("aborted", () => {
234
- clientAborted = true;
235
- log("warn", "client aborted — destroying upstream", { totalChunks }, rid);
236
- if (!nodeStream.destroyed) nodeStream.destroy();
237
- });
238
-
239
- req.on("close", () => {
240
- if (!upstreamEnded && !nodeStream.destroyed) {
241
- clientAborted = true;
242
- nodeStream.destroy();
243
- }
244
- });
245
-
246
- res.on("close", () => {
247
- if (
248
- !upstreamEnded &&
249
- !res.writableEnded &&
250
- !nodeStream.destroyed
251
- ) {
252
- clientAborted = true;
253
- nodeStream.destroy();
254
- }
255
- });
256
-
257
- res.on("error", () => {
258
- if (!upstreamEnded && !nodeStream.destroyed) {
259
- clientAborted = true;
260
- nodeStream.destroy();
261
- }
262
- });
263
- }
1
+ /**
2
+ * Upstream SSE streaming pipeline with thinking sniffing and exception isolation.
3
+ *
4
+ * Prevents upstream stream aborts or timeouts from crashing the host process.
5
+ * Sniffs thinking and reasoning chunks for debug trace logging without payload mutation.
6
+ */
7
+
8
+ import { randomUUID } from "node:crypto";
9
+ import type * as http from "node:http";
10
+ import type { Readable } from "node:stream";
11
+ import { isDebugEnabled, log } from "./logger.ts";
12
+ import { markRelayFailure } from "./relay-state.ts";
13
+ /**
14
+ * Pipes an upstream readable stream to a client HTTP response.
15
+ *
16
+ * - Flushes HTTP headers immediately for low Time-to-First-Byte (TTFB).
17
+ * - Detects thinking/reasoning tokens in chunks for diagnostic logs.
18
+ * - Handles upstream errors (returns 502 if headers unsent) and ends response.
19
+ * - Cleans up resources and destroys upstream stream on client disconnect/abort.
20
+ */
21
+ export function pipeUpstreamStream(
22
+ nodeStream: Readable,
23
+ res: http.ServerResponse,
24
+ req: http.IncomingMessage,
25
+ reqId?: string,
26
+ relayUrl?: string,
27
+ ): void {
28
+ const rid = reqId || randomUUID().slice(0, 8);
29
+ let totalChunks = 0;
30
+ let totalBytes = 0;
31
+ let thinkingChunks = 0;
32
+ let thinkingBytes = 0;
33
+ let firstChunkAt: number | null = null;
34
+ const startAt = Date.now();
35
+ const isResponsesApi = req.url?.includes("/responses") ?? false;
36
+ let hasTerminalEvent = false;
37
+ // Abort-cause tracking: only genuine upstream-side truncation may penalize
38
+ // relay health. Client disconnects and clean upstream ends must not.
39
+ let upstreamEnded = false;
40
+ let clientAborted = false;
41
+ // Terminal-marker scan carry: holds the tail of the previously scanned
42
+ // view so a marker split across adjacent chunks ("[DO" | "NE]") is still
43
+ // recognized. Longest marker is "response.completed" (18 bytes); 32
44
+ // gives comfortable headroom.
45
+ let terminalScanCarry = Buffer.alloc(0);
46
+
47
+ const sniffThinking = (chunk: Buffer | string): boolean => {
48
+ const s =
49
+ typeof chunk === "string"
50
+ ? chunk
51
+ : chunk.toString("utf8", 0, Math.min(chunk.length, 4000));
52
+ return (
53
+ s.includes("reasoning") ||
54
+ s.includes("thinking") ||
55
+ s.includes("<think>") ||
56
+ s.includes("reasoning_content") ||
57
+ s.includes('"type":"thinking"') ||
58
+ s.includes("thinking_delta")
59
+ );
60
+ };
61
+
62
+ const checkTerminalEvent = (s: string): boolean => {
63
+ return (
64
+ s.includes("response.completed") ||
65
+ s.includes("response.done") ||
66
+ s.includes("response.failed") ||
67
+ s.includes("response.incomplete") ||
68
+ s.includes("[DONE]")
69
+ );
70
+ };
71
+ const ensureTerminalEvent = (
72
+ isError = false,
73
+ errorMsg?: string,
74
+ penalizeRelay = true,
75
+ ) => {
76
+ if (hasTerminalEvent || res.writableEnded) return;
77
+ // Only genuine upstream-side truncation penalizes relay health.
78
+ // Client disconnects and clean upstream ends leave it untouched.
79
+ if (penalizeRelay && relayUrl && relayUrl !== "direct") {
80
+ markRelayFailure(relayUrl, 0, errorMsg || "stream truncated prematurely");
81
+ }
82
+ if (isResponsesApi && totalChunks > 0) {
83
+ try {
84
+ if (isError) {
85
+ res.write(
86
+ `\nevent: response.failed\ndata: {"type":"response.failed","response":{"status":"failed","error":{"code":"stream_error","message":${JSON.stringify(errorMsg || "Upstream stream disconnected unexpectedly")}}}}\n\n`,
87
+ );
88
+ } else {
89
+ res.write(
90
+ `\nevent: response.incomplete\ndata: {"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"cancelled"}}}\n\n`,
91
+ );
92
+ }
93
+ hasTerminalEvent = true;
94
+ log(
95
+ "warn",
96
+ `injected synthetic response.${isError ? "failed" : "incomplete"} for prematurely truncated stream`,
97
+ { totalChunks, totalBytes, isError, errorMsg },
98
+ rid,
99
+ );
100
+ } catch {}
101
+ } else if (!isResponsesApi && totalChunks > 0) {
102
+ try {
103
+ res.write("\ndata: [DONE]\n\n");
104
+ hasTerminalEvent = true;
105
+ } catch {}
106
+ }
107
+ };
108
+
109
+ try {
110
+ if (typeof res.flushHeaders === "function") {
111
+ res.flushHeaders();
112
+ }
113
+ } catch {}
114
+ nodeStream.on("data", (chunk: Buffer | string) => {
115
+ try {
116
+ if (firstChunkAt === null) {
117
+ firstChunkAt = Date.now();
118
+ const ttfb = firstChunkAt - startAt;
119
+ log("debug", `stream first chunk in ${ttfb}ms`, undefined, rid);
120
+ }
121
+ totalChunks++;
122
+ const chunkSize =
123
+ typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length;
124
+ totalBytes += chunkSize;
125
+
126
+ if (sniffThinking(chunk)) {
127
+ thinkingChunks++;
128
+ thinkingBytes += chunkSize;
129
+ if (isDebugEnabled() && thinkingChunks <= 3) {
130
+ const preview =
131
+ typeof chunk === "string"
132
+ ? chunk.slice(0, 600)
133
+ : chunk.toString("utf8", 0, 600);
134
+ log(
135
+ "debug",
136
+ `thinking chunk #${thinkingChunks}`,
137
+ { preview: preview.slice(0, 400) },
138
+ rid,
139
+ );
140
+ }
141
+ }
142
+ // Always scan the FULL chunk plus the small carry from the previous
143
+ // view. SSE chunks are KBs at most, so includes() over everything
144
+ // is negligible against correctness — the removed head/tail windows
145
+ // are exactly what let markers buried mid-chunk or split across
146
+ // adjacent chunks escape and cause duplicate synthetic terminals.
147
+ const buf =
148
+ typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
149
+ const scanBuf =
150
+ terminalScanCarry.length > 0
151
+ ? Buffer.concat([terminalScanCarry, buf])
152
+ : buf;
153
+ if (!hasTerminalEvent && checkTerminalEvent(scanBuf.toString("utf8"))) {
154
+ hasTerminalEvent = true;
155
+ }
156
+ terminalScanCarry = Buffer.from(scanBuf.subarray(Math.max(0, scanBuf.length - 32)));
157
+
158
+ res.write(chunk);
159
+ const maybeFlush = res as unknown as { flush?: () => void };
160
+ if (typeof maybeFlush.flush === "function") {
161
+ maybeFlush.flush();
162
+ }
163
+ } catch {}
164
+ });
165
+
166
+ nodeStream.on("error", (e: unknown) => {
167
+ const errorMsg = (e as Error)?.message || String(e);
168
+ log(
169
+ "error",
170
+ "upstream stream error",
171
+ { error: errorMsg, totalChunks, thinkingChunks, hasTerminalEvent },
172
+ rid,
173
+ );
174
+ try {
175
+ if (!res.headersSent) {
176
+ res.writeHead(502, { "content-type": "application/json" });
177
+ } else {
178
+ ensureTerminalEvent(true, errorMsg);
179
+ }
180
+ if (!res.writableEnded) {
181
+ res.end();
182
+ }
183
+ } catch {}
184
+ });
185
+
186
+ nodeStream.on("end", () => {
187
+ upstreamEnded = true;
188
+ const elapsed = ((Date.now() - startAt) / 1000).toFixed(1);
189
+ if (!hasTerminalEvent && totalChunks > 0) {
190
+ // Clean upstream end without a detectable marker: keep the host
191
+ // contract (synthetic terminal) but never blame the relay.
192
+ ensureTerminalEvent(
193
+ false,
194
+ "upstream ended without terminal marker",
195
+ false,
196
+ );
197
+ }
198
+ if (thinkingChunks > 0) {
199
+ log(
200
+ "info",
201
+ `stream ended in ${elapsed}s — ${totalChunks} chunks (${(totalBytes / 1024).toFixed(1)}KB), thinking: ${thinkingChunks} chunks (${(thinkingBytes / 1024).toFixed(1)}KB)`,
202
+ undefined,
203
+ rid,
204
+ );
205
+ } else if (isDebugEnabled()) {
206
+ log(
207
+ "debug",
208
+ `stream ended in ${elapsed}s — ${totalChunks} chunks (${(totalBytes / 1024).toFixed(1)}KB), no thinking detected`,
209
+ undefined,
210
+ rid,
211
+ );
212
+ }
213
+ try {
214
+ if (!res.writableEnded) res.end();
215
+ } catch {}
216
+ });
217
+
218
+ nodeStream.on("close", () => {
219
+ try {
220
+ if (!hasTerminalEvent && totalChunks > 0) {
221
+ if (clientAborted || upstreamEnded) {
222
+ // Client disconnect teardown or post-end cleanup — the relay
223
+ // is not at fault; still give the host a terminal event.
224
+ ensureTerminalEvent(false, "stream interrupted by client", false);
225
+ } else {
226
+ // Upstream socket died mid-stream with no error event:
227
+ // genuine upstream truncation — penalize the relay.
228
+ ensureTerminalEvent(true, "stream closed prematurely");
229
+ }
230
+ }
231
+ if (!res.writableEnded) res.end();
232
+ } catch {}
233
+ });
234
+
235
+ req.on("aborted", () => {
236
+ clientAborted = true;
237
+ log("warn", "client aborted — destroying upstream", { totalChunks }, rid);
238
+ if (!nodeStream.destroyed) nodeStream.destroy();
239
+ });
240
+
241
+ req.on("close", () => {
242
+ if (!upstreamEnded && !nodeStream.destroyed) {
243
+ clientAborted = true;
244
+ nodeStream.destroy();
245
+ }
246
+ });
247
+
248
+ res.on("close", () => {
249
+ if (
250
+ !upstreamEnded &&
251
+ !res.writableEnded &&
252
+ !nodeStream.destroyed
253
+ ) {
254
+ clientAborted = true;
255
+ nodeStream.destroy();
256
+ }
257
+ });
258
+
259
+ res.on("error", () => {
260
+ if (!upstreamEnded && !nodeStream.destroyed) {
261
+ clientAborted = true;
262
+ nodeStream.destroy();
263
+ }
264
+ });
265
+ }