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/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
- STRIP_HEADERS,
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 (STRIP_HEADERS.has(lower) || lower.startsWith(":")) continue;
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
- res.writeHead(204, {
143
- "access-control-allow-origin": "*",
144
- "access-control-allow-methods": "GET, POST, OPTIONS",
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
- res.writeHead(403, { "content-type": "application/json" });
183
- res.end(JSON.stringify({ error: "forbidden" }));
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
- req.on("data", (chunk: Buffer) => bodyChunks.push(chunk));
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({ error: "rate limit exceeded" }));
290
+ res.end(JSON.stringify(body));
233
291
  return;
234
292
  }
235
293
 
236
294
  try {
237
295
  if (isKilo && parsedBody) {
238
- const kiloBodyObj = structuredClone(parsedBody);
239
- const response = await relayFetch(
240
- KILO_CHAT_URL,
241
- {
242
- method: "POST",
243
- headers: {
244
- "Content-Type": "application/json",
245
- Authorization: "Bearer kilo-free",
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 relayBodyObj = structuredClone(parsedBody);
298
- sanitizeReasoningForModel(relayBodyObj as Record<string, unknown>);
299
- const relayBody = Buffer.from(JSON.stringify(relayBodyObj));
300
- const response = await relayFetch(
301
- fullUrl,
302
- {
303
- method: req.method || "POST",
304
- headers: relayHeaders,
305
- body: relayBody,
306
- signal: AbortSignal.timeout(300_000),
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 — with debug trace and thinking-aware normalization
352
- let directBody = Buffer.concat(bodyChunks);
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);