pi-freeflow 1.2.0 → 1.2.1

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 ADDED
@@ -0,0 +1,467 @@
1
+ /**
2
+ * Single-port local HTTP proxy and dynamic upstream router for pi-freeflow
3
+ *
4
+ * Provides loopback proxying on port 18080 (shared across parent and subagents),
5
+ * intelligent routing to OpenCode Zen and KiloCode Gateway, and failover support.
6
+ */
7
+
8
+ import { randomUUID } from "node:crypto";
9
+ import * as http from "node:http";
10
+ import * as https from "node:https";
11
+ import { Readable } from "node:stream";
12
+ import type { ReadableStream as WebReadableStream } from "node:stream/web";
13
+ import { getAliveCatalog } from "./catalog.ts";
14
+ import {
15
+ ALLOWED_METHODS,
16
+ ALLOWED_PATH_PATTERN,
17
+ HOST,
18
+ KILO_CHAT_URL,
19
+ PATH_TRAVERSAL_PATTERN,
20
+ PORT,
21
+ STRIP_HEADERS,
22
+ UPSTREAM_OPENCODE,
23
+ opencodeHeaders,
24
+ } from "./config.ts";
25
+ import { isDebugEnabled, log } from "./logger.ts";
26
+ import { KILO_MODEL_IDS } from "./models.ts";
27
+ import { normalizeRequestBody } from "./normalizer.ts";
28
+ import { checkRateLimit } from "./rate-limiter.ts";
29
+ import { relayFetch } from "./relay.ts";
30
+ import { getActiveRelayState } from "./relay-state.ts";
31
+ import { pipeUpstreamStream } from "./stream-pipe.ts";
32
+ import type { Upstream } from "./types.ts";
33
+
34
+ /**
35
+ * Extract client IP address from incoming HTTP request.
36
+ */
37
+ export function getClientIP(req: http.IncomingMessage): string {
38
+ const addr = req.socket.remoteAddress;
39
+ if (!addr) return "unknown";
40
+ return addr.startsWith("::ffff:") ? addr.slice(7) : addr;
41
+ }
42
+
43
+ /**
44
+ * Validate that the request URL matches allowed API path patterns and prevents path traversal.
45
+ */
46
+ export function validatePath(rawUrl: string): URL | null {
47
+ const cleaned = rawUrl.replace(/^\/+/, "");
48
+ if (!ALLOWED_PATH_PATTERN.test(`/${cleaned}`)) return null;
49
+ if (PATH_TRAVERSAL_PATTERN.test(cleaned)) return null;
50
+ try {
51
+ const decoded = decodeURIComponent(cleaned);
52
+ if (decoded !== cleaned && !ALLOWED_PATH_PATTERN.test(`/${decoded}`)) {
53
+ return null;
54
+ }
55
+ } catch {
56
+ return null;
57
+ }
58
+ try {
59
+ return new URL(cleaned, `${UPSTREAM_OPENCODE}/`);
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Sanitize and inject standard headers before forwarding request to upstream.
67
+ */
68
+ export function sanitizeHeaders(
69
+ incoming: http.IncomingHttpHeaders,
70
+ targetHost: string,
71
+ ): Record<string, string> {
72
+ const sanitized: Record<string, string> = {};
73
+ for (const [key, value] of Object.entries(incoming)) {
74
+ const lower = key.toLowerCase();
75
+ if (STRIP_HEADERS.has(lower) || lower.startsWith(":")) continue;
76
+ if (typeof value === "string") sanitized[lower] = value;
77
+ else if (Array.isArray(value)) sanitized[lower] = value.join(", ");
78
+ }
79
+ sanitized.host = targetHost;
80
+ // Drop the client's own user-agent so opencodeHeaders() cannot produce a
81
+ // duplicate (case-differing) User-Agent pair — upstream resets connections
82
+ // that send two conflicting User-Agent headers.
83
+ delete sanitized["user-agent"];
84
+ Object.assign(sanitized, opencodeHeaders());
85
+ sanitized["accept-encoding"] = "identity";
86
+ sanitized.connection = "keep-alive";
87
+ return sanitized;
88
+ }
89
+
90
+ /**
91
+ * Probe whether an existing pi-freeflow proxy daemon is running and responsive on a given port.
92
+ */
93
+ export async function isProxyAlive(port: number): Promise<boolean> {
94
+ try {
95
+ const res = await fetch(`http://${HOST}:${port}/v1/models`, {
96
+ signal: AbortSignal.timeout(500),
97
+ });
98
+ const ct = res.headers.get("content-type") || "";
99
+ return res.ok && ct.includes("application/json");
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Start the local HTTP proxy daemon.
107
+ *
108
+ * Implements master/worker single-port reuse: if port 18080 is already held by a live
109
+ * parent or sibling OMP session, resolves immediately with { server: null, port: 18080 }.
110
+ */
111
+ export function startProxy(
112
+ overridePort?: number,
113
+ ): Promise<{ server: http.Server | null; port: number }> {
114
+ const basePort = overridePort ?? PORT;
115
+
116
+ const server = http.createServer((req, res) => {
117
+ const clientIP = getClientIP(req);
118
+ const reqId = randomUUID().slice(0, 8);
119
+ if (isDebugEnabled()) {
120
+ log(
121
+ "debug",
122
+ `incoming ${req.method} ${req.url} from ${clientIP}`,
123
+ { ip: clientIP, method: req.method, url: req.url },
124
+ reqId,
125
+ );
126
+ }
127
+
128
+ if (!ALLOWED_METHODS.has(req.method ?? "")) {
129
+ res.writeHead(405, { "content-type": "application/json" });
130
+ res.end(JSON.stringify({ error: "method not allowed" }));
131
+ return;
132
+ }
133
+
134
+ if (req.method === "OPTIONS") {
135
+ res.writeHead(204, {
136
+ "access-control-allow-origin": "*",
137
+ "access-control-allow-methods": "GET, POST, OPTIONS",
138
+ "access-control-max-age": "86400",
139
+ });
140
+ res.end();
141
+ return;
142
+ }
143
+
144
+ // Serve ONLY our registered free models. Never forward /v1/models to upstream
145
+ // to prevent paid/proprietary upstream models from leaking into the model picker.
146
+ if (
147
+ req.method === "GET" &&
148
+ (req.url === "/v1/models" || req.url === "/v1/models/")
149
+ ) {
150
+ const alive = getAliveCatalog();
151
+ const body = JSON.stringify({
152
+ object: "list",
153
+ data: alive.map((m) => ({
154
+ id: m.id,
155
+ object: "model",
156
+ created: 0,
157
+ owned_by: m.source === "kilo" ? "kilocode" : "opencode",
158
+ })),
159
+ });
160
+ res.writeHead(200, {
161
+ "content-type": "application/json",
162
+ "content-length": Buffer.byteLength(body),
163
+ });
164
+ res.end(body);
165
+ return;
166
+ }
167
+
168
+ const target = validatePath(req.url ?? "/");
169
+ if (!target) {
170
+ res.writeHead(403, { "content-type": "application/json" });
171
+ res.end(JSON.stringify({ error: "forbidden" }));
172
+ return;
173
+ }
174
+
175
+ // Buffer request body to inspect model ID for upstream routing
176
+ const bodyChunks: Buffer[] = [];
177
+ req.on("error", (err) => {
178
+ log(
179
+ "warn",
180
+ "client request error during body buffering",
181
+ { error: String(err) },
182
+ reqId,
183
+ );
184
+ if (!res.headersSent) {
185
+ res.writeHead(400, { "content-type": "application/json" });
186
+ }
187
+ res.end(JSON.stringify({ error: "bad request" }));
188
+ });
189
+
190
+ req.on("data", (chunk: Buffer) => bodyChunks.push(chunk));
191
+
192
+ req.on("end", async () => {
193
+ const bodyStr = Buffer.concat(bodyChunks).toString();
194
+ let isKilo = false;
195
+ let parsedBody: Record<string, unknown> | null = null;
196
+
197
+ try {
198
+ parsedBody = JSON.parse(bodyStr);
199
+ if (
200
+ typeof parsedBody?.model === "string" &&
201
+ KILO_MODEL_IDS.has(parsedBody.model)
202
+ ) {
203
+ isKilo = true;
204
+ }
205
+ } catch {}
206
+
207
+ const upstream: Upstream = isKilo ? "kilo" : "opencode";
208
+ const isStream = parsedBody?.stream === true;
209
+
210
+ if (!checkRateLimit(clientIP, upstream)) {
211
+ res.writeHead(429, { "content-type": "application/json" });
212
+ res.end(JSON.stringify({ error: "rate limit exceeded" }));
213
+ return;
214
+ }
215
+
216
+ try {
217
+ if (isKilo && parsedBody) {
218
+ const kiloBodyObj = structuredClone(parsedBody);
219
+ normalizeRequestBody(kiloBodyObj, true, isKilo, reqId);
220
+ const response = await relayFetch(
221
+ KILO_CHAT_URL,
222
+ {
223
+ method: "POST",
224
+ headers: {
225
+ "Content-Type": "application/json",
226
+ Authorization: "Bearer kilo-free",
227
+ },
228
+ body: JSON.stringify(kiloBodyObj),
229
+ signal: AbortSignal.timeout(300_000),
230
+ },
231
+ reqId,
232
+ );
233
+
234
+ if (isStream && response.ok && response.body) {
235
+ const ct =
236
+ response.headers.get("content-type") || "text/event-stream";
237
+ res.writeHead(response.status, {
238
+ "content-type": ct,
239
+ "cache-control": "no-cache, no-transform",
240
+ connection: "keep-alive",
241
+ "x-accel-buffering": "no",
242
+ });
243
+ pipeUpstreamStream(
244
+ Readable.fromWeb(
245
+ response.body as unknown as WebReadableStream,
246
+ ),
247
+ res,
248
+ req,
249
+ reqId,
250
+ );
251
+ } else {
252
+ const data = await response.text();
253
+ const ct =
254
+ response.headers.get("content-type") || "application/json";
255
+ res.writeHead(response.status, { "content-type": ct });
256
+ res.end(data);
257
+ }
258
+ } else {
259
+ // OpenCode routing — relay when enabled, else direct upstream
260
+ const relayState = getActiveRelayState();
261
+ if (
262
+ relayState.enabled &&
263
+ (relayState.url || relayState.relays.length > 0)
264
+ ) {
265
+ const fullUrl = `${UPSTREAM_OPENCODE}${req.url ?? "/"}`;
266
+ const activeHost = relayState.url
267
+ ? new URL(relayState.url).host
268
+ : "opencode.ai";
269
+ const relayHeaders = sanitizeHeaders(req.headers, activeHost);
270
+
271
+ try {
272
+ if (parsedBody) {
273
+ const relayBodyObj = structuredClone(parsedBody);
274
+ normalizeRequestBody(relayBodyObj, true, isKilo, reqId);
275
+ const relayBody = Buffer.from(JSON.stringify(relayBodyObj));
276
+ const response = await relayFetch(
277
+ fullUrl,
278
+ {
279
+ method: req.method || "POST",
280
+ headers: relayHeaders,
281
+ body: relayBody,
282
+ signal: AbortSignal.timeout(300_000),
283
+ },
284
+ reqId,
285
+ );
286
+
287
+ if (isStream && response.ok && response.body) {
288
+ const ct =
289
+ response.headers.get("content-type") ||
290
+ "text/event-stream";
291
+ res.writeHead(response.status, {
292
+ "content-type": ct,
293
+ "cache-control": "no-cache, no-transform",
294
+ connection: "keep-alive",
295
+ "x-accel-buffering": "no",
296
+ });
297
+ pipeUpstreamStream(
298
+ Readable.fromWeb(
299
+ response.body as unknown as WebReadableStream,
300
+ ),
301
+ res,
302
+ req,
303
+ reqId,
304
+ );
305
+ } else {
306
+ const data = await response.text();
307
+ const ct =
308
+ response.headers.get("content-type") ||
309
+ "application/json";
310
+ res.writeHead(response.status, { "content-type": ct });
311
+ res.end(data);
312
+ }
313
+ return; // relay handled successfully
314
+ }
315
+ } catch (e) {
316
+ log(
317
+ "warn",
318
+ "opencode relay failed, falling back to direct upstream",
319
+ { error: String(e) },
320
+ reqId,
321
+ );
322
+ if (res.headersSent) return; // cannot recover mid-stream
323
+ }
324
+ }
325
+
326
+ // Direct path — with debug trace and thinking-aware normalization
327
+ let directBody = Buffer.concat(bodyChunks);
328
+ if (parsedBody) {
329
+ const directBodyObj = structuredClone(parsedBody);
330
+ normalizeRequestBody(directBodyObj, false, isKilo, reqId);
331
+ directBody = Buffer.from(JSON.stringify(directBodyObj));
332
+ }
333
+
334
+ if (isDebugEnabled()) {
335
+ log(
336
+ "debug",
337
+ `direct upstream ${target.hostname}${target.pathname} (${directBody.length}B)`,
338
+ { model: parsedBody?.model, isKilo },
339
+ reqId,
340
+ );
341
+ }
342
+
343
+ const fwd = sanitizeHeaders(req.headers, target.hostname);
344
+ if (directBody.length > 0) {
345
+ fwd["content-length"] = String(directBody.byteLength);
346
+ }
347
+
348
+ const proxy = https.request(
349
+ {
350
+ method: req.method,
351
+ hostname: target.hostname,
352
+ port: 443,
353
+ path: target.pathname + target.search,
354
+ headers: fwd,
355
+ },
356
+ (upstream) => {
357
+ const outHeaders: Record<string, string> = {};
358
+ for (const h of [
359
+ "content-type",
360
+ "cache-control",
361
+ "x-request-id",
362
+ ]) {
363
+ const val = upstream.headers[h];
364
+ if (typeof val === "string") outHeaders[h] = val;
365
+ }
366
+ outHeaders["x-content-type-options"] = "nosniff";
367
+ res.writeHead(upstream.statusCode ?? 502, outHeaders);
368
+ upstream.on("error", (streamErr) => {
369
+ log(
370
+ "error",
371
+ "upstream stream error in direct proxy",
372
+ { error: String(streamErr) },
373
+ reqId,
374
+ );
375
+ if (!res.writableEnded) res.end();
376
+ });
377
+ upstream.pipe(res);
378
+ },
379
+ );
380
+
381
+ proxy.on("error", (proxyErr) => {
382
+ log(
383
+ "error",
384
+ "proxy socket error",
385
+ { error: String(proxyErr) },
386
+ reqId,
387
+ );
388
+ if (!res.headersSent) {
389
+ res.writeHead(502, { "content-type": "application/json" });
390
+ res.end(JSON.stringify({ error: "upstream error" }));
391
+ } else if (!res.writableEnded) {
392
+ res.end();
393
+ }
394
+ });
395
+
396
+ proxy.setTimeout(300_000, () => {
397
+ proxy.destroy(new Error("timeout"));
398
+ });
399
+
400
+ // Premature client disconnect guard.
401
+ // Node >= 19 emits req 'close' right after the request body 'end',
402
+ // so destroying on req close/aborted kills every healthy upstream
403
+ // socket milliseconds after creation. Only tear down when the
404
+ // client response connection actually drops mid-flight.
405
+ const destroyIfClientGone = () => {
406
+ if (!res.writableEnded && !proxy.destroyed) proxy.destroy();
407
+ };
408
+ req.on("error", destroyIfClientGone);
409
+ res.on("close", destroyIfClientGone);
410
+
411
+ proxy.end(directBody);
412
+ }
413
+ } catch (err) {
414
+ log("error", "proxy error", { error: String(err) }, reqId);
415
+ if (!res.headersSent) {
416
+ res.writeHead(502, { "content-type": "application/json" });
417
+ }
418
+ res.end(JSON.stringify({ error: "internal error" }));
419
+ }
420
+ });
421
+ });
422
+
423
+ return new Promise<{ server: http.Server | null; port: number }>(
424
+ (resolve, reject) => {
425
+ let attempt = 0;
426
+ let settled = false;
427
+
428
+ const tryListen = async (port: number) => {
429
+ server.once("error", async (err: NodeJS.ErrnoException) => {
430
+ if (settled) return;
431
+ if (err.code === "EADDRINUSE") {
432
+ // Re-check if the base port is alive (attached master race)
433
+ if (await isProxyAlive(basePort)) {
434
+ settled = true;
435
+ log(
436
+ "info",
437
+ `attached to running proxy on http://${HOST}:${basePort}`,
438
+ );
439
+ resolve({ server: null, port: basePort });
440
+ return;
441
+ }
442
+ if (attempt < 20) {
443
+ attempt++;
444
+ log("warn", `port ${port} taken — trying ${port + 1}`);
445
+ tryListen(port + 1);
446
+ return;
447
+ }
448
+ }
449
+ settled = true;
450
+ log("error", "server error", { code: err.code, message: err.message });
451
+ reject(err);
452
+ });
453
+
454
+ server.listen(port, HOST, () => {
455
+ if (settled) return;
456
+ settled = true;
457
+ const addr = server.address();
458
+ const realPort = addr && typeof addr === "object" ? addr.port : port;
459
+ log("info", `proxy listening on http://${HOST}:${realPort}`);
460
+ resolve({ server, port: realPort });
461
+ });
462
+ };
463
+
464
+ tryListen(basePort);
465
+ },
466
+ );
467
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Memory-safe sliding rate limiter for pi-freeflow
3
+ * Enforces:
4
+ * - OpenCode Zen: 200 requests per UTC day per IP
5
+ * - KiloCode Gateway: 200 requests per 1-hour window per IP
6
+ */
7
+
8
+ import { RATE_LIMIT_MAX } from "./config.ts";
9
+ import type { RateLimitEntry, RateLimitStatus, Upstream } from "./types.ts";
10
+
11
+ const rateLimitMap = new Map<string, RateLimitEntry>();
12
+ let lastCleanupAt = 0;
13
+ const CLEANUP_INTERVAL_MS = 60_000; // 1 minute
14
+ const MAX_MAP_SIZE_BEFORE_CLEANUP = 500;
15
+
16
+ /**
17
+ * Calculate the next reset timestamp in epoch milliseconds.
18
+ */
19
+ export function rateLimitResetAt(upstream: Upstream, now: number): number {
20
+ if (upstream === "kilo") {
21
+ return now + 60 * 60_000; // 1 hour sliding window
22
+ }
23
+ // OpenCode resets at 00:00:00.000 UTC of next day
24
+ const nextUtcDay = new Date(now);
25
+ nextUtcDay.setUTCHours(24, 0, 0, 0);
26
+ return nextUtcDay.getTime();
27
+ }
28
+
29
+ /**
30
+ * Construct a cache key for an upstream + client IP.
31
+ */
32
+ export function rateLimitKey(
33
+ upstream: Upstream,
34
+ ip: string,
35
+ now: number = Date.now(),
36
+ ): string {
37
+ const safeIp = ip.trim() || "127.0.0.1";
38
+ if (upstream === "kilo") {
39
+ return `kilo:${safeIp}`;
40
+ }
41
+ const utcDate = new Date(now).toISOString().slice(0, 10);
42
+ return `opencode:${utcDate}:${safeIp}`;
43
+ }
44
+
45
+ /**
46
+ * Purge expired rate limit buckets to guarantee bounded memory usage.
47
+ * Returns the number of evicted entries.
48
+ */
49
+ export function cleanupRateLimits(now: number = Date.now()): number {
50
+ let evicted = 0;
51
+ for (const [key, entry] of rateLimitMap.entries()) {
52
+ if (entry.resetAt <= now) {
53
+ rateLimitMap.delete(key);
54
+ evicted++;
55
+ }
56
+ }
57
+ lastCleanupAt = now;
58
+ return evicted;
59
+ }
60
+
61
+ /**
62
+ * Trigger cleanup if interval elapsed or map has grown past the high watermark.
63
+ */
64
+ function maybeCleanup(now: number): void {
65
+ if (
66
+ now - lastCleanupAt > CLEANUP_INTERVAL_MS ||
67
+ rateLimitMap.size > MAX_MAP_SIZE_BEFORE_CLEANUP
68
+ ) {
69
+ cleanupRateLimits(now);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Check and consume a quota token for the given IP and upstream.
75
+ * Returns true if request is permitted, false if rate limit exceeded.
76
+ */
77
+ export function checkRateLimit(
78
+ ip: string,
79
+ upstream: Upstream,
80
+ now: number = Date.now(),
81
+ ): boolean {
82
+ maybeCleanup(now);
83
+
84
+ const key = rateLimitKey(upstream, ip, now);
85
+ const entry = rateLimitMap.get(key);
86
+ const maxLimit = RATE_LIMIT_MAX[upstream] ?? 200;
87
+
88
+ if (!entry || entry.resetAt <= now) {
89
+ rateLimitMap.set(key, {
90
+ count: 1,
91
+ resetAt: rateLimitResetAt(upstream, now),
92
+ });
93
+ return true;
94
+ }
95
+
96
+ if (entry.count >= maxLimit) {
97
+ return false;
98
+ }
99
+
100
+ entry.count++;
101
+ return true;
102
+ }
103
+
104
+ /**
105
+ * Query current rate limit quota and remaining requests without mutating count.
106
+ */
107
+ export function getRateLimitStatus(
108
+ ip: string,
109
+ upstream: Upstream,
110
+ now: number = Date.now(),
111
+ ): RateLimitStatus {
112
+ const key = rateLimitKey(upstream, ip, now);
113
+ const entry = rateLimitMap.get(key);
114
+ const limit = RATE_LIMIT_MAX[upstream] ?? 200;
115
+
116
+ if (!entry || entry.resetAt <= now) {
117
+ return {
118
+ allowed: true,
119
+ remaining: limit,
120
+ resetAt: rateLimitResetAt(upstream, now),
121
+ limit,
122
+ count: 0,
123
+ };
124
+ }
125
+
126
+ const remaining = Math.max(0, limit - entry.count);
127
+ return {
128
+ allowed: remaining > 0,
129
+ remaining,
130
+ resetAt: entry.resetAt,
131
+ limit,
132
+ count: entry.count,
133
+ };
134
+ }
135
+ /**
136
+ * Clear all rate limit records (primarily for testing and reset commands).
137
+ */
138
+ export function resetRateLimits(): void {
139
+ rateLimitMap.clear();
140
+ lastCleanupAt = Date.now();
141
+ }
142
+
143
+ /**
144
+ * Get active count of entries in the rate limit table.
145
+ */
146
+ export function getRateLimitMapSize(): number {
147
+ return rateLimitMap.size;
148
+ }