pi-freeflow 1.3.6 → 1.3.8
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/package.json +1 -1
- package/src/commands.ts +9 -1
- package/src/proxy.ts +16 -10
- package/src/relay-state.ts +87 -8
- package/src/relay.ts +9 -1
- package/src/stream-pipe.ts +64 -3
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.3.
|
|
4
|
+
"version": "1.3.8",
|
|
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/commands.ts
CHANGED
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
ensureRelay,
|
|
20
20
|
findRelay,
|
|
21
21
|
getActiveRelayState,
|
|
22
|
+
getRelayHealth,
|
|
23
|
+
isRelayHealthy,
|
|
22
24
|
removeRelay,
|
|
23
25
|
saveRelayState,
|
|
24
26
|
setActiveRelayState,
|
|
@@ -254,7 +256,13 @@ export function createCommandSpec(
|
|
|
254
256
|
const star = r.url === relayState.url ? "★" : " ";
|
|
255
257
|
const shortName = r.label ? `[${r.label}]` : `[${shortRelayLabel(r.url, relayState.relays)}]`;
|
|
256
258
|
const paddedName = shortName.padEnd(16, " ");
|
|
257
|
-
|
|
259
|
+
const health = getRelayHealth(r.url);
|
|
260
|
+
const isCooling = health && Date.now() < health.cooldownUntil;
|
|
261
|
+
const remainingSec = isCooling ? Math.ceil((health.cooldownUntil - Date.now()) / 1000) : 0;
|
|
262
|
+
const healthBadge = isCooling
|
|
263
|
+
? ` ⚠️ [cooling ${remainingSec}s: ${health.lastStatus ? `HTTP ${health.lastStatus}` : "error"}]`
|
|
264
|
+
: " ✓";
|
|
265
|
+
return `${star} [${idx + 1}] ${paddedName} → ${r.url}${healthBadge}`;
|
|
258
266
|
});
|
|
259
267
|
const activeLabel = shortRelayLabel(relayState.url, relayState.relays);
|
|
260
268
|
const activeIdx = Math.max(
|
package/src/proxy.ts
CHANGED
|
@@ -273,6 +273,7 @@ export function startProxy(
|
|
|
273
273
|
res,
|
|
274
274
|
req,
|
|
275
275
|
reqId,
|
|
276
|
+
relayPreview.url,
|
|
276
277
|
);
|
|
277
278
|
} else {
|
|
278
279
|
const data = await response.text();
|
|
@@ -328,6 +329,7 @@ export function startProxy(
|
|
|
328
329
|
res,
|
|
329
330
|
req,
|
|
330
331
|
reqId,
|
|
332
|
+
relayState.url,
|
|
331
333
|
);
|
|
332
334
|
} else {
|
|
333
335
|
const data = await response.text();
|
|
@@ -392,16 +394,20 @@ export function startProxy(
|
|
|
392
394
|
}
|
|
393
395
|
outHeaders["x-content-type-options"] = "nosniff";
|
|
394
396
|
res.writeHead(upstream.statusCode ?? 502, outHeaders);
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
397
|
+
if (isStream) {
|
|
398
|
+
pipeUpstreamStream(upstream, res, req, reqId, "direct");
|
|
399
|
+
} else {
|
|
400
|
+
upstream.on("error", (streamErr) => {
|
|
401
|
+
log(
|
|
402
|
+
"error",
|
|
403
|
+
"upstream stream error in direct proxy",
|
|
404
|
+
{ error: String(streamErr) },
|
|
405
|
+
reqId,
|
|
406
|
+
);
|
|
407
|
+
if (!res.writableEnded) res.end();
|
|
408
|
+
});
|
|
409
|
+
upstream.pipe(res);
|
|
410
|
+
}
|
|
405
411
|
},
|
|
406
412
|
);
|
|
407
413
|
|
package/src/relay-state.ts
CHANGED
|
@@ -170,6 +170,81 @@ let activeRelayState: RelayState = resolveRelayState();
|
|
|
170
170
|
let roundRobinCounter = 0;
|
|
171
171
|
let activeStatusUi: ExtensionUIContext | null = null;
|
|
172
172
|
let isFreeFlowModelActive = true;
|
|
173
|
+
|
|
174
|
+
export interface RelayHealth {
|
|
175
|
+
consecutiveFailures: number;
|
|
176
|
+
lastFailureTime: number;
|
|
177
|
+
cooldownUntil: number;
|
|
178
|
+
lastStatus?: number;
|
|
179
|
+
lastError?: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const relayHealthMap = new Map<string, RelayHealth>();
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Mark a relay as healthy and active on successful response.
|
|
186
|
+
*/
|
|
187
|
+
export function markRelaySuccess(url: string): void {
|
|
188
|
+
if (!url) return;
|
|
189
|
+
relayHealthMap.delete(url.trim());
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Mark a relay as degraded with temporary cooldown on failure/429/timeout/socket error.
|
|
194
|
+
*/
|
|
195
|
+
export function markRelayFailure(url: string, status?: number, error?: string): void {
|
|
196
|
+
if (!url) return;
|
|
197
|
+
const clean = url.trim();
|
|
198
|
+
const prev = relayHealthMap.get(clean) || {
|
|
199
|
+
consecutiveFailures: 0,
|
|
200
|
+
lastFailureTime: 0,
|
|
201
|
+
cooldownUntil: 0,
|
|
202
|
+
};
|
|
203
|
+
const consecutive = prev.consecutiveFailures + 1;
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
let cooldownMs = 30_000; // 30s default for socket/network/502/503
|
|
206
|
+
|
|
207
|
+
if (status === 429) {
|
|
208
|
+
cooldownMs = 90_000; // 90s cooldown for upstream rate limits
|
|
209
|
+
} else if (status === 504) {
|
|
210
|
+
cooldownMs = 60_000; // 60s cooldown for gateway timeout
|
|
211
|
+
} else if (status && status >= 500) {
|
|
212
|
+
cooldownMs = 45_000; // 45s for 5xx errors
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
relayHealthMap.set(clean, {
|
|
216
|
+
consecutiveFailures: consecutive,
|
|
217
|
+
lastFailureTime: now,
|
|
218
|
+
cooldownUntil: now + cooldownMs,
|
|
219
|
+
lastStatus: status,
|
|
220
|
+
lastError: error,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Check if a relay is currently healthy (not in active cooldown).
|
|
226
|
+
*/
|
|
227
|
+
export function isRelayHealthy(url: string): boolean {
|
|
228
|
+
if (!url) return true;
|
|
229
|
+
const clean = url.trim();
|
|
230
|
+
const health = relayHealthMap.get(clean);
|
|
231
|
+
if (!health) return true;
|
|
232
|
+
return Date.now() >= health.cooldownUntil;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Get current health snapshot for a relay.
|
|
237
|
+
*/
|
|
238
|
+
export function getRelayHealth(url: string): RelayHealth | undefined {
|
|
239
|
+
return relayHealthMap.get(url.trim());
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Reset all in-memory relay health records.
|
|
244
|
+
*/
|
|
245
|
+
export function resetAllRelayHealth(): void {
|
|
246
|
+
relayHealthMap.clear();
|
|
247
|
+
}
|
|
173
248
|
/**
|
|
174
249
|
* Mtime of the on-disk state file at the moment we last read or wrote it.
|
|
175
250
|
* session's master daemon, while never clobbering this process's own
|
|
@@ -280,19 +355,23 @@ export function getOrderedRelayUrls(): string[] {
|
|
|
280
355
|
// Rotate starting point per-request to avoid thundering herd when many
|
|
281
356
|
// subagents hit the shared 127.0.0.1 daemon at once — each request
|
|
282
357
|
// tries a different primary relay, but still rolls seamlessly on 429.
|
|
283
|
-
const
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
358
|
+
const totalRelays = activeRelayState.relays.length;
|
|
359
|
+
const startIdx = (activeIdx + (roundRobinCounter++ % totalRelays)) % totalRelays;
|
|
360
|
+
const rawOrdered: string[] = [];
|
|
361
|
+
for (let i = 0; i < totalRelays; i++) {
|
|
362
|
+
const r = activeRelayState.relays[(startIdx + i) % totalRelays];
|
|
287
363
|
if (r?.url?.trim()) {
|
|
288
|
-
|
|
364
|
+
rawOrdered.push(r.url.trim());
|
|
289
365
|
}
|
|
290
366
|
}
|
|
367
|
+
|
|
368
|
+
// Partition into healthy candidates first, degraded/cooling candidates at the tail
|
|
369
|
+
const healthy = rawOrdered.filter((u) => isRelayHealthy(u));
|
|
370
|
+
const cooling = rawOrdered.filter((u) => !isRelayHealthy(u));
|
|
371
|
+
const ordered = [...healthy, ...cooling];
|
|
372
|
+
|
|
291
373
|
return ordered.length > 0 ? ordered : [DEFAULT_RELAY_URL];
|
|
292
374
|
}
|
|
293
|
-
if (activeRelayState.url?.trim()) {
|
|
294
|
-
return [activeRelayState.url.trim()];
|
|
295
|
-
}
|
|
296
375
|
return [DEFAULT_RELAY_URL];
|
|
297
376
|
}
|
|
298
377
|
|
package/src/relay.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { isDebugEnabled, log } from "./logger.ts";
|
|
|
11
11
|
import {
|
|
12
12
|
getActiveRelayState,
|
|
13
13
|
getOrderedRelayUrls,
|
|
14
|
+
markRelayFailure,
|
|
15
|
+
markRelaySuccess,
|
|
14
16
|
saveRelayState,
|
|
15
17
|
setActiveRelayState,
|
|
16
18
|
shortRelayLabel,
|
|
@@ -106,6 +108,7 @@ export async function relayFetch(
|
|
|
106
108
|
// Vercel 504 Gateway Timeout on heavy prompts (>50KB or >25s):
|
|
107
109
|
// Fast fallback directly to upstream instead of cycling through multiple 25s timeouts.
|
|
108
110
|
if (res.status === 504) {
|
|
111
|
+
markRelayFailure(targetUrl, 504, "Gateway Timeout (25s exceeded)");
|
|
109
112
|
log(
|
|
110
113
|
"warn",
|
|
111
114
|
`relay ${targetUrl} hit HTTP 504 Gateway Timeout in ${elapsed}s (prompt evaluation exceeded Vercel 25s limit) — fast fallback to direct upstream`,
|
|
@@ -116,6 +119,7 @@ export async function relayFetch(
|
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
if (isRetriableStatus(res.status)) {
|
|
122
|
+
markRelayFailure(targetUrl, res.status);
|
|
119
123
|
lastResponse = res;
|
|
120
124
|
log(
|
|
121
125
|
"warn",
|
|
@@ -126,6 +130,8 @@ export async function relayFetch(
|
|
|
126
130
|
continue;
|
|
127
131
|
}
|
|
128
132
|
|
|
133
|
+
markRelaySuccess(targetUrl);
|
|
134
|
+
|
|
129
135
|
// SUCCESS or non-retriable client error (e.g. 200, 404):
|
|
130
136
|
// If we switched to a different relay because previous failed, update sticky active relay!
|
|
131
137
|
if (relayState.url !== targetUrl) {
|
|
@@ -151,10 +157,12 @@ export async function relayFetch(
|
|
|
151
157
|
} catch (err) {
|
|
152
158
|
const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
|
|
153
159
|
lastError = err;
|
|
160
|
+
const errMsg = (err as Error)?.message || String(err);
|
|
161
|
+
markRelayFailure(targetUrl, 0, errMsg);
|
|
154
162
|
log(
|
|
155
163
|
"warn",
|
|
156
164
|
`relay ${targetUrl} fetch error in ${elapsed}s — rolling to next relay`,
|
|
157
|
-
{ upstream: url, error:
|
|
165
|
+
{ upstream: url, error: errMsg },
|
|
158
166
|
rid,
|
|
159
167
|
);
|
|
160
168
|
continue;
|
package/src/stream-pipe.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto";
|
|
|
9
9
|
import type * as http from "node:http";
|
|
10
10
|
import type { Readable } from "node:stream";
|
|
11
11
|
import { isDebugEnabled, log } from "./logger.ts";
|
|
12
|
-
|
|
12
|
+
import { markRelayFailure } from "./relay-state.ts";
|
|
13
13
|
/**
|
|
14
14
|
* Pipes an upstream readable stream to a client HTTP response.
|
|
15
15
|
*
|
|
@@ -23,6 +23,7 @@ export function pipeUpstreamStream(
|
|
|
23
23
|
res: http.ServerResponse,
|
|
24
24
|
req: http.IncomingMessage,
|
|
25
25
|
reqId?: string,
|
|
26
|
+
relayUrl?: string,
|
|
26
27
|
): void {
|
|
27
28
|
const rid = reqId || randomUUID().slice(0, 8);
|
|
28
29
|
let totalChunks = 0;
|
|
@@ -31,6 +32,8 @@ export function pipeUpstreamStream(
|
|
|
31
32
|
let thinkingBytes = 0;
|
|
32
33
|
let firstChunkAt: number | null = null;
|
|
33
34
|
const startAt = Date.now();
|
|
35
|
+
const isResponsesApi = req.url?.includes("/responses") ?? false;
|
|
36
|
+
let hasTerminalEvent = false;
|
|
34
37
|
|
|
35
38
|
const sniffThinking = (chunk: Buffer | string): boolean => {
|
|
36
39
|
const s =
|
|
@@ -47,12 +50,53 @@ export function pipeUpstreamStream(
|
|
|
47
50
|
);
|
|
48
51
|
};
|
|
49
52
|
|
|
53
|
+
const checkTerminalEvent = (s: string): boolean => {
|
|
54
|
+
return (
|
|
55
|
+
s.includes("response.completed") ||
|
|
56
|
+
s.includes("response.done") ||
|
|
57
|
+
s.includes("response.failed") ||
|
|
58
|
+
s.includes("response.incomplete") ||
|
|
59
|
+
s.includes("[DONE]")
|
|
60
|
+
);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const ensureTerminalEvent = (isError = false, errorMsg?: string) => {
|
|
64
|
+
if (hasTerminalEvent || res.writableEnded) return;
|
|
65
|
+
if (relayUrl && relayUrl !== "direct") {
|
|
66
|
+
markRelayFailure(relayUrl, 0, errorMsg || "stream truncated prematurely");
|
|
67
|
+
}
|
|
68
|
+
if (isResponsesApi && totalChunks > 0) {
|
|
69
|
+
try {
|
|
70
|
+
if (isError) {
|
|
71
|
+
res.write(
|
|
72
|
+
`\nevent: response.failed\ndata: {"type":"response.failed","response":{"status":"failed","error":{"code":"stream_error","message":${JSON.stringify(errorMsg || "Upstream stream disconnected unexpectedly")}}}}\n\n`,
|
|
73
|
+
);
|
|
74
|
+
} else {
|
|
75
|
+
res.write(
|
|
76
|
+
`\nevent: response.incomplete\ndata: {"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"cancelled"}}}\n\n`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
hasTerminalEvent = true;
|
|
80
|
+
log(
|
|
81
|
+
"warn",
|
|
82
|
+
`injected synthetic response.${isError ? "failed" : "incomplete"} for prematurely truncated stream`,
|
|
83
|
+
{ totalChunks, totalBytes, isError, errorMsg },
|
|
84
|
+
rid,
|
|
85
|
+
);
|
|
86
|
+
} catch {}
|
|
87
|
+
} else if (!isResponsesApi && totalChunks > 0) {
|
|
88
|
+
try {
|
|
89
|
+
res.write("\ndata: [DONE]\n\n");
|
|
90
|
+
hasTerminalEvent = true;
|
|
91
|
+
} catch {}
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
50
95
|
try {
|
|
51
96
|
if (typeof res.flushHeaders === "function") {
|
|
52
97
|
res.flushHeaders();
|
|
53
98
|
}
|
|
54
99
|
} catch {}
|
|
55
|
-
|
|
56
100
|
nodeStream.on("data", (chunk: Buffer | string) => {
|
|
57
101
|
try {
|
|
58
102
|
if (firstChunkAt === null) {
|
|
@@ -82,6 +126,14 @@ export function pipeUpstreamStream(
|
|
|
82
126
|
}
|
|
83
127
|
}
|
|
84
128
|
|
|
129
|
+
const strPreview =
|
|
130
|
+
typeof chunk === "string"
|
|
131
|
+
? chunk
|
|
132
|
+
: chunk.toString("utf8", 0, Math.min(chunk.length, 2000));
|
|
133
|
+
if (!hasTerminalEvent && checkTerminalEvent(strPreview)) {
|
|
134
|
+
hasTerminalEvent = true;
|
|
135
|
+
}
|
|
136
|
+
|
|
85
137
|
res.write(chunk);
|
|
86
138
|
const maybeFlush = res as unknown as { flush?: () => void };
|
|
87
139
|
if (typeof maybeFlush.flush === "function") {
|
|
@@ -91,15 +143,18 @@ export function pipeUpstreamStream(
|
|
|
91
143
|
});
|
|
92
144
|
|
|
93
145
|
nodeStream.on("error", (e: unknown) => {
|
|
146
|
+
const errorMsg = (e as Error)?.message || String(e);
|
|
94
147
|
log(
|
|
95
148
|
"error",
|
|
96
149
|
"upstream stream error",
|
|
97
|
-
{ error:
|
|
150
|
+
{ error: errorMsg, totalChunks, thinkingChunks, hasTerminalEvent },
|
|
98
151
|
rid,
|
|
99
152
|
);
|
|
100
153
|
try {
|
|
101
154
|
if (!res.headersSent) {
|
|
102
155
|
res.writeHead(502, { "content-type": "application/json" });
|
|
156
|
+
} else {
|
|
157
|
+
ensureTerminalEvent(true, errorMsg);
|
|
103
158
|
}
|
|
104
159
|
if (!res.writableEnded) {
|
|
105
160
|
res.end();
|
|
@@ -109,6 +164,9 @@ export function pipeUpstreamStream(
|
|
|
109
164
|
|
|
110
165
|
nodeStream.on("end", () => {
|
|
111
166
|
const elapsed = ((Date.now() - startAt) / 1000).toFixed(1);
|
|
167
|
+
if (!hasTerminalEvent && totalChunks > 0) {
|
|
168
|
+
ensureTerminalEvent(false);
|
|
169
|
+
}
|
|
112
170
|
if (thinkingChunks > 0) {
|
|
113
171
|
log(
|
|
114
172
|
"info",
|
|
@@ -131,6 +189,9 @@ export function pipeUpstreamStream(
|
|
|
131
189
|
|
|
132
190
|
nodeStream.on("close", () => {
|
|
133
191
|
try {
|
|
192
|
+
if (!hasTerminalEvent && totalChunks > 0) {
|
|
193
|
+
ensureTerminalEvent(true, "stream closed prematurely");
|
|
194
|
+
}
|
|
134
195
|
if (!res.writableEnded) res.end();
|
|
135
196
|
} catch {}
|
|
136
197
|
});
|