pi-freeflow 1.5.0 → 1.7.0
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/README.md +62 -86
- package/package.json +2 -3
- package/src/catalog.ts +21 -12
- package/src/commands.ts +308 -146
- package/src/config.ts +24 -2
- package/src/deploy.ts +128 -185
- package/src/health.ts +2 -7
- package/src/index.ts +22 -4
- package/src/logger.ts +52 -24
- package/src/models.ts +139 -67
- package/src/probe.ts +49 -0
- package/src/proxy.ts +150 -57
- package/src/relay-state.ts +380 -69
- package/src/relay.ts +72 -26
- package/src/stream-pipe.ts +307 -271
- package/src/types.ts +4 -2
- package/src/update-checker.ts +21 -5
package/src/proxy.ts
CHANGED
|
@@ -18,8 +18,9 @@ import {
|
|
|
18
18
|
HOST,
|
|
19
19
|
KILO_CHAT_URL,
|
|
20
20
|
PATH_TRAVERSAL_PATTERN,
|
|
21
|
+
MAX_BODY_BYTES,
|
|
21
22
|
PORT,
|
|
22
|
-
|
|
23
|
+
UPSTREAM_HEADER_TIMEOUT_MS,
|
|
23
24
|
UPSTREAM_OPENCODE,
|
|
24
25
|
opencodeHeaders,
|
|
25
26
|
} from "./config.ts";
|
|
@@ -27,11 +28,25 @@ import { isDebugEnabled, log } from "./logger.ts";
|
|
|
27
28
|
import { KILO_MODEL_IDS, resolveCanonicalModelId } from "./models.ts";
|
|
28
29
|
// normalize removed — host pi-ai already normalizes thinking/reasoning before proxy
|
|
29
30
|
import { checkRateLimit } from "./rate-limiter.ts";
|
|
30
|
-
import { agent, relayFetch } from "./relay.ts";
|
|
31
|
+
import { agent, canUseCustomDispatcher, relayFetch } from "./relay.ts";
|
|
31
32
|
import { getActiveRelayState } from "./relay-state.ts";
|
|
32
33
|
import { pipeUpstreamStream } from "./stream-pipe.ts";
|
|
33
34
|
import type { Upstream } from "./types.ts";
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Direct-mode 429 hint throttle: the guidance hint is emitted at most once per
|
|
38
|
+
* 10 minutes per process so repeated rate-limit responses don't spam clients.
|
|
39
|
+
*/
|
|
40
|
+
let last429HintAt = 0;
|
|
41
|
+
function shouldShow429Hint(): boolean {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
if (now - last429HintAt < 10 * 60 * 1000) return false;
|
|
44
|
+
last429HintAt = now;
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
/** Test-only: reset 429 hint throttle */
|
|
48
|
+
export function _reset429HintForTest(): void { last429HintAt = 0; }
|
|
49
|
+
|
|
35
50
|
/**
|
|
36
51
|
* Extract client IP address from incoming HTTP request.
|
|
37
52
|
*/
|
|
@@ -70,10 +85,20 @@ export function sanitizeHeaders(
|
|
|
70
85
|
incoming: http.IncomingHttpHeaders,
|
|
71
86
|
targetHost: string,
|
|
72
87
|
): Record<string, string> {
|
|
88
|
+
// Positive allowlist: only headers the proxy is meant to relay. Everything
|
|
89
|
+
// else (host, cookie, x-forwarded-*, client x-relay-*) is dropped so a
|
|
90
|
+
// browser/extension cannot smuggle headers into the upstream request.
|
|
91
|
+
const allowed: Record<string, true> = {
|
|
92
|
+
"content-type": true,
|
|
93
|
+
accept: true,
|
|
94
|
+
authorization: true,
|
|
95
|
+
"x-request-id": true,
|
|
96
|
+
};
|
|
73
97
|
const sanitized: Record<string, string> = {};
|
|
74
98
|
for (const [key, value] of Object.entries(incoming)) {
|
|
75
99
|
const lower = key.toLowerCase();
|
|
76
|
-
if (
|
|
100
|
+
if (lower.startsWith(":")) continue;
|
|
101
|
+
if (!allowed[lower] && !lower.startsWith("x-opencode-")) continue;
|
|
77
102
|
if (typeof value === "string") sanitized[lower] = value;
|
|
78
103
|
else if (Array.isArray(value)) sanitized[lower] = value.join(", ");
|
|
79
104
|
}
|
|
@@ -87,17 +112,6 @@ export function sanitizeHeaders(
|
|
|
87
112
|
sanitized.connection = "keep-alive";
|
|
88
113
|
return sanitized;
|
|
89
114
|
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Reasoning normalization is owned by host pi-ai; proxy passes through reasoning fields unchanged.
|
|
93
|
-
* Kept as no-op for compatibility — no per-model clamping.
|
|
94
|
-
*/
|
|
95
|
-
function sanitizeReasoningForModel(_bodyObj: Record<string, unknown>): void {
|
|
96
|
-
// no-op
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* Probe whether an existing pi-freeflow proxy daemon is running and responsive on a given port.
|
|
100
|
-
*/
|
|
101
115
|
export async function isProxyAlive(port: number): Promise<boolean> {
|
|
102
116
|
try {
|
|
103
117
|
const res = await fetch(`http://${HOST}:${port}/v1/models`, {
|
|
@@ -110,6 +124,21 @@ export async function isProxyAlive(port: number): Promise<boolean> {
|
|
|
110
124
|
}
|
|
111
125
|
}
|
|
112
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Tagged abort reason for the proxy-internal header-wait timeout.
|
|
129
|
+
* relayFetch rethrows AbortErrors untouched, and stream-pipe recognizes
|
|
130
|
+
* code FF_INTERNAL_ABORT as "our abort, not a relay fault" — so neither
|
|
131
|
+
* rolls nor penalizes a healthy relay when the request merely ran slow.
|
|
132
|
+
*/
|
|
133
|
+
function upstreamTimeoutError(): Error & { code: string } {
|
|
134
|
+
const err = new Error(
|
|
135
|
+
`upstream header timeout (${UPSTREAM_HEADER_TIMEOUT_MS}ms)`,
|
|
136
|
+
) as Error & { code: string };
|
|
137
|
+
err.name = "AbortError";
|
|
138
|
+
err.code = "FF_INTERNAL_ABORT";
|
|
139
|
+
return err;
|
|
140
|
+
}
|
|
141
|
+
|
|
113
142
|
/**
|
|
114
143
|
* Start the local HTTP proxy daemon.
|
|
115
144
|
* Implements master/worker single-port reuse: if port 28180 is already held by a live
|
|
@@ -139,11 +168,9 @@ export function startProxy(
|
|
|
139
168
|
}
|
|
140
169
|
|
|
141
170
|
if (req.method === "OPTIONS") {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
"access-control-max-age": "86400",
|
|
146
|
-
});
|
|
171
|
+
// No CORS headers: loopback proxy is not a cross-origin resource.
|
|
172
|
+
// Keep a bare 204 so OPTIONS never reaches validatePath/upstream.
|
|
173
|
+
res.writeHead(204);
|
|
147
174
|
res.end();
|
|
148
175
|
return;
|
|
149
176
|
}
|
|
@@ -179,13 +206,27 @@ export function startProxy(
|
|
|
179
206
|
|
|
180
207
|
const target = validatePath(req.url ?? "/");
|
|
181
208
|
if (!target) {
|
|
182
|
-
|
|
183
|
-
|
|
209
|
+
const cleaned = (req.url ?? "/").replace(/^\/+/, "");
|
|
210
|
+
if (PATH_TRAVERSAL_PATTERN.test(cleaned)) {
|
|
211
|
+
res.writeHead(403, { "content-type": "application/json" });
|
|
212
|
+
res.end(JSON.stringify({ error: "forbidden" }));
|
|
213
|
+
} else {
|
|
214
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
215
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
216
|
+
}
|
|
184
217
|
return;
|
|
185
218
|
}
|
|
186
|
-
|
|
187
219
|
// Buffer request body to inspect model ID for upstream routing
|
|
188
220
|
const bodyChunks: Buffer[] = [];
|
|
221
|
+
// Reject oversized bodies before buffering starts: the client declares
|
|
222
|
+
// the size in content-length, so no transfer cost is wasted.
|
|
223
|
+
const declaredLength = Number(req.headers["content-length"] ?? 0);
|
|
224
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
|
|
225
|
+
res.writeHead(413, { "content-type": "application/json" });
|
|
226
|
+
res.end(JSON.stringify({ error: "payload too large" }));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
let bufferedBytes = 0;
|
|
189
230
|
req.on("error", (err) => {
|
|
190
231
|
log(
|
|
191
232
|
"warn",
|
|
@@ -199,7 +240,20 @@ export function startProxy(
|
|
|
199
240
|
res.end(JSON.stringify({ error: "bad request" }));
|
|
200
241
|
});
|
|
201
242
|
|
|
202
|
-
|
|
243
|
+
// Running cap for chunked/undeclared bodies: stop buffering the instant
|
|
244
|
+
// the limit is crossed instead of holding the whole payload in memory.
|
|
245
|
+
req.on("data", (chunk: Buffer) => {
|
|
246
|
+
bufferedBytes += chunk.length;
|
|
247
|
+
if (bufferedBytes > MAX_BODY_BYTES) {
|
|
248
|
+
req.destroy();
|
|
249
|
+
if (!res.headersSent) {
|
|
250
|
+
res.writeHead(413, { "content-type": "application/json" });
|
|
251
|
+
res.end(JSON.stringify({ error: "payload too large" }));
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
bodyChunks.push(chunk);
|
|
256
|
+
});
|
|
203
257
|
|
|
204
258
|
req.on("end", async () => {
|
|
205
259
|
const bodyStr = Buffer.concat(bodyChunks).toString();
|
|
@@ -228,27 +282,51 @@ export function startProxy(
|
|
|
228
282
|
const relayPreview = getActiveRelayState();
|
|
229
283
|
const willUseRelay = relayPreview.enabled && Boolean(relayPreview.url || relayPreview.relays.length > 0);
|
|
230
284
|
if (!willUseRelay && !checkRateLimit(clientIP, upstream)) {
|
|
285
|
+
const body: Record<string, unknown> = { error: "rate limit exceeded" };
|
|
286
|
+
if (shouldShow429Hint()) {
|
|
287
|
+
body.hint = "Shared free-tier IP quota reached. Add your own relay egress: /freeflow deploy (Vercel 1M/mo recommended)";
|
|
288
|
+
}
|
|
231
289
|
res.writeHead(429, { "content-type": "application/json" });
|
|
232
|
-
res.end(JSON.stringify(
|
|
290
|
+
res.end(JSON.stringify(body));
|
|
233
291
|
return;
|
|
234
292
|
}
|
|
235
293
|
|
|
236
294
|
try {
|
|
237
295
|
if (isKilo && parsedBody) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
},
|
|
247
|
-
body: JSON.stringify(kiloBodyObj),
|
|
248
|
-
signal: AbortSignal.timeout(300_000),
|
|
249
|
-
},
|
|
250
|
-
reqId,
|
|
296
|
+
// Header-wait timeout + client-disconnect abort: once headers
|
|
297
|
+
// arrive the timer is cleared so a long stream is not killed at
|
|
298
|
+
// the timeout ceiling; the stream phase is owned by
|
|
299
|
+
// pipeUpstreamStream and its close handling.
|
|
300
|
+
const kiloController = new AbortController();
|
|
301
|
+
const kiloTimeoutId = setTimeout(
|
|
302
|
+
() => kiloController.abort(upstreamTimeoutError()),
|
|
303
|
+
UPSTREAM_HEADER_TIMEOUT_MS,
|
|
251
304
|
);
|
|
305
|
+
const abortKiloOnClientGone = () => {
|
|
306
|
+
if (!res.writableEnded) kiloController.abort();
|
|
307
|
+
};
|
|
308
|
+
res.once("close", abortKiloOnClientGone);
|
|
309
|
+
req.once("error", abortKiloOnClientGone);
|
|
310
|
+
let response: Response;
|
|
311
|
+
try {
|
|
312
|
+
response = await relayFetch(
|
|
313
|
+
KILO_CHAT_URL,
|
|
314
|
+
{
|
|
315
|
+
method: "POST",
|
|
316
|
+
headers: {
|
|
317
|
+
"Content-Type": "application/json",
|
|
318
|
+
Authorization: "Bearer kilo-free",
|
|
319
|
+
},
|
|
320
|
+
body: JSON.stringify(parsedBody),
|
|
321
|
+
signal: kiloController.signal,
|
|
322
|
+
},
|
|
323
|
+
reqId,
|
|
324
|
+
);
|
|
325
|
+
} finally {
|
|
326
|
+
clearTimeout(kiloTimeoutId);
|
|
327
|
+
res.off("close", abortKiloOnClientGone);
|
|
328
|
+
req.off("error", abortKiloOnClientGone);
|
|
329
|
+
}
|
|
252
330
|
|
|
253
331
|
if (isStream && response.ok && response.body) {
|
|
254
332
|
const ct =
|
|
@@ -294,19 +372,39 @@ export function startProxy(
|
|
|
294
372
|
|
|
295
373
|
try {
|
|
296
374
|
if (parsedBody) {
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
},
|
|
308
|
-
reqId,
|
|
375
|
+
const relayBody = Buffer.concat(bodyChunks);
|
|
376
|
+
// Header-wait timeout + client-disconnect abort; the
|
|
377
|
+
// timer is cleared once headers arrive so streams are
|
|
378
|
+
// not killed at the timeout ceiling. Aborts caused by
|
|
379
|
+
// our own timeout are tagged FF_INTERNAL_ABORT so
|
|
380
|
+
// stream-pipe never penalizes the relay for them.
|
|
381
|
+
const relayController = new AbortController();
|
|
382
|
+
const relayTimeoutId = setTimeout(
|
|
383
|
+
() => relayController.abort(upstreamTimeoutError()),
|
|
384
|
+
UPSTREAM_HEADER_TIMEOUT_MS,
|
|
309
385
|
);
|
|
386
|
+
const abortRelayOnClientGone = () => {
|
|
387
|
+
if (!res.writableEnded) relayController.abort();
|
|
388
|
+
};
|
|
389
|
+
res.once("close", abortRelayOnClientGone);
|
|
390
|
+
req.once("error", abortRelayOnClientGone);
|
|
391
|
+
let response: Response;
|
|
392
|
+
try {
|
|
393
|
+
response = await relayFetch(
|
|
394
|
+
fullUrl,
|
|
395
|
+
{
|
|
396
|
+
method: req.method || "POST",
|
|
397
|
+
headers: relayHeaders,
|
|
398
|
+
body: relayBody,
|
|
399
|
+
signal: relayController.signal,
|
|
400
|
+
},
|
|
401
|
+
reqId,
|
|
402
|
+
);
|
|
403
|
+
} finally {
|
|
404
|
+
clearTimeout(relayTimeoutId);
|
|
405
|
+
res.off("close", abortRelayOnClientGone);
|
|
406
|
+
req.off("error", abortRelayOnClientGone);
|
|
407
|
+
}
|
|
310
408
|
|
|
311
409
|
if (isStream && response.ok && response.body) {
|
|
312
410
|
const ct =
|
|
@@ -348,13 +446,8 @@ export function startProxy(
|
|
|
348
446
|
}
|
|
349
447
|
}
|
|
350
448
|
|
|
351
|
-
// Direct path —
|
|
352
|
-
|
|
353
|
-
if (parsedBody) {
|
|
354
|
-
const directBodyObj = structuredClone(parsedBody);
|
|
355
|
-
sanitizeReasoningForModel(directBodyObj as Record<string, unknown>);
|
|
356
|
-
directBody = Buffer.from(JSON.stringify(directBodyObj));
|
|
357
|
-
}
|
|
449
|
+
// Direct path — the relay already parsed/forwarded raw; send the buffered bytes unchanged
|
|
450
|
+
const directBody = Buffer.concat(bodyChunks);
|
|
358
451
|
|
|
359
452
|
if (isDebugEnabled()) {
|
|
360
453
|
log(
|
|
@@ -386,7 +479,7 @@ export function startProxy(
|
|
|
386
479
|
headers: fwd,
|
|
387
480
|
body: directBody.length > 0 ? directBody : undefined,
|
|
388
481
|
signal: controller.signal,
|
|
389
|
-
dispatcher: agent,
|
|
482
|
+
...(canUseCustomDispatcher ? { dispatcher: agent } : {}),
|
|
390
483
|
} as unknown as RequestInit);
|
|
391
484
|
clearTimeout(timeoutId);
|
|
392
485
|
res.off("close", onClientClose);
|