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.
- package/README.md +325 -279
- package/package.json +1 -1
- package/src/catalog.ts +222 -207
- package/src/commands.ts +670 -618
- package/src/config.ts +144 -145
- package/src/deploy.ts +496 -126
- package/src/index.ts +301 -277
- package/src/models.ts +399 -399
- package/src/proxy.ts +503 -500
- package/src/relay.ts +213 -210
- package/src/stream-pipe.ts +265 -263
package/src/proxy.ts
CHANGED
|
@@ -1,500 +1,503 @@
|
|
|
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, resolveCanonicalModelId } from "./models.ts";
|
|
27
|
-
// normalize removed — host pi-ai already normalizes thinking/reasoning before proxy
|
|
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
|
-
* Clamps reasoning_effort for upstream models with strict non-standard enums
|
|
92
|
-
* (e.g. OpenCode x-preview strictly requires 'low', 'high', or 'max' and rejects 'medium' with 400).
|
|
93
|
-
*/
|
|
94
|
-
function sanitizeReasoningForModel(bodyObj: Record<string, unknown>): void {
|
|
95
|
-
const model = String(bodyObj.model || "").toLowerCase();
|
|
96
|
-
if (model.includes("x-preview")) {
|
|
97
|
-
const effort = String(bodyObj.reasoning_effort || "").toLowerCase();
|
|
98
|
-
if (effort === "medium") {
|
|
99
|
-
bodyObj.reasoning_effort = "high";
|
|
100
|
-
} else if (effort === "minimal") {
|
|
101
|
-
bodyObj.reasoning_effort = "low";
|
|
102
|
-
} else if (!effort || effort === "off" || effort === "none") {
|
|
103
|
-
bodyObj.reasoning_effort = "low";
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
/**
|
|
108
|
-
* Probe whether an existing pi-freeflow proxy daemon is running and responsive on a given port.
|
|
109
|
-
*/
|
|
110
|
-
export async function isProxyAlive(port: number): Promise<boolean> {
|
|
111
|
-
try {
|
|
112
|
-
const res = await fetch(`http://${HOST}:${port}/v1/models`, {
|
|
113
|
-
signal: AbortSignal.timeout(500),
|
|
114
|
-
});
|
|
115
|
-
const ct = res.headers.get("content-type") || "";
|
|
116
|
-
return res.ok && ct.includes("application/json");
|
|
117
|
-
} catch {
|
|
118
|
-
return false;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Start the local HTTP proxy daemon.
|
|
124
|
-
*
|
|
125
|
-
* Implements master/worker single-port reuse: if port 18080 is already held by a live
|
|
126
|
-
* parent or sibling OMP session, resolves immediately with { server: null, port: 18080 }.
|
|
127
|
-
*/
|
|
128
|
-
export function startProxy(
|
|
129
|
-
overridePort?: number,
|
|
130
|
-
): Promise<{ server: http.Server | null; port: number }> {
|
|
131
|
-
const basePort = overridePort ?? PORT;
|
|
132
|
-
|
|
133
|
-
const server = http.createServer((req, res) => {
|
|
134
|
-
const clientIP = getClientIP(req);
|
|
135
|
-
const reqId = randomUUID().slice(0, 8);
|
|
136
|
-
if (isDebugEnabled()) {
|
|
137
|
-
log(
|
|
138
|
-
"debug",
|
|
139
|
-
`incoming ${req.method} ${req.url} from ${clientIP}`,
|
|
140
|
-
{ ip: clientIP, method: req.method, url: req.url },
|
|
141
|
-
reqId,
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (!ALLOWED_METHODS.has(req.method ?? "")) {
|
|
146
|
-
res.writeHead(405, { "content-type": "application/json" });
|
|
147
|
-
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (req.method === "OPTIONS") {
|
|
152
|
-
res.writeHead(204, {
|
|
153
|
-
"access-control-allow-origin": "*",
|
|
154
|
-
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
155
|
-
"access-control-max-age": "86400",
|
|
156
|
-
});
|
|
157
|
-
res.end();
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Serve ONLY our registered free models. Never forward /v1/models to upstream
|
|
162
|
-
// to prevent paid/proprietary upstream models from leaking into the model picker.
|
|
163
|
-
// Use pathname check so /v1/models?query variants are also guarded (no leak).
|
|
164
|
-
let reqPathname: string | null = null;
|
|
165
|
-
try {
|
|
166
|
-
reqPathname = new URL(req.url ?? "/", `http://${HOST}`).pathname;
|
|
167
|
-
} catch {}
|
|
168
|
-
if (req.method === "GET" && (reqPathname === "/v1/models" || reqPathname === "/v1/models/")) {
|
|
169
|
-
const alive = getAliveCatalog();
|
|
170
|
-
const body = JSON.stringify({
|
|
171
|
-
object: "list",
|
|
172
|
-
data: alive.map((m) => ({
|
|
173
|
-
id: m.id,
|
|
174
|
-
object: "model",
|
|
175
|
-
created: 0,
|
|
176
|
-
owned_by: m.source === "kilo" ? "kilocode" : "opencode",
|
|
177
|
-
})),
|
|
178
|
-
});
|
|
179
|
-
res.writeHead(200, {
|
|
180
|
-
"content-type": "application/json",
|
|
181
|
-
"content-length": Buffer.byteLength(body),
|
|
182
|
-
});
|
|
183
|
-
res.end(body);
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const target = validatePath(req.url ?? "/");
|
|
188
|
-
if (!target) {
|
|
189
|
-
res.writeHead(403, { "content-type": "application/json" });
|
|
190
|
-
res.end(JSON.stringify({ error: "forbidden" }));
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Buffer request body to inspect model ID for upstream routing
|
|
195
|
-
const bodyChunks: Buffer[] = [];
|
|
196
|
-
req.on("error", (err) => {
|
|
197
|
-
log(
|
|
198
|
-
"warn",
|
|
199
|
-
"client request error during body buffering",
|
|
200
|
-
{ error: String(err) },
|
|
201
|
-
reqId,
|
|
202
|
-
);
|
|
203
|
-
if (!res.headersSent) {
|
|
204
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
205
|
-
}
|
|
206
|
-
res.end(JSON.stringify({ error: "bad request" }));
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
req.on("data", (chunk: Buffer) => bodyChunks.push(chunk));
|
|
210
|
-
|
|
211
|
-
req.on("end", async () => {
|
|
212
|
-
const bodyStr = Buffer.concat(bodyChunks).toString();
|
|
213
|
-
let isKilo = false;
|
|
214
|
-
let parsedBody: Record<string, unknown> | null = null;
|
|
215
|
-
|
|
216
|
-
try {
|
|
217
|
-
parsedBody = JSON.parse(bodyStr);
|
|
218
|
-
if (typeof parsedBody?.model === "string") {
|
|
219
|
-
const canonical = resolveCanonicalModelId(parsedBody.model);
|
|
220
|
-
parsedBody.model = canonical;
|
|
221
|
-
if (KILO_MODEL_IDS.has(canonical)) {
|
|
222
|
-
isKilo = true;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
} catch {}
|
|
226
|
-
|
|
227
|
-
const upstream: Upstream = isKilo ? "kilo" : "opencode";
|
|
228
|
-
const isStream = parsedBody?.stream === true;
|
|
229
|
-
|
|
230
|
-
// Seamless sub-agent rate-limit: when relay pool is active, bypass
|
|
231
|
-
// local per-IP quota (127.0.0.1 shared by all subagents) — upstream
|
|
232
|
-
// quota is per-egress-IP and relayFetch already rolls on 429 across
|
|
233
|
-
// 7 candidates until a response succeeds. Without this, parallel
|
|
234
|
-
// subagents sharing the daemon would hit local 429 before relay failover.
|
|
235
|
-
const relayPreview = getActiveRelayState();
|
|
236
|
-
const willUseRelay = relayPreview.enabled && Boolean(relayPreview.url || relayPreview.relays.length > 0);
|
|
237
|
-
if (!willUseRelay && !checkRateLimit(clientIP, upstream)) {
|
|
238
|
-
res.writeHead(429, { "content-type": "application/json" });
|
|
239
|
-
res.end(JSON.stringify({ error: "rate limit exceeded" }));
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
try {
|
|
244
|
-
if (isKilo && parsedBody) {
|
|
245
|
-
const kiloBodyObj = structuredClone(parsedBody);
|
|
246
|
-
const response = await relayFetch(
|
|
247
|
-
KILO_CHAT_URL,
|
|
248
|
-
{
|
|
249
|
-
method: "POST",
|
|
250
|
-
headers: {
|
|
251
|
-
"Content-Type": "application/json",
|
|
252
|
-
Authorization: "Bearer kilo-free",
|
|
253
|
-
},
|
|
254
|
-
body: JSON.stringify(kiloBodyObj),
|
|
255
|
-
signal: AbortSignal.timeout(300_000),
|
|
256
|
-
},
|
|
257
|
-
reqId,
|
|
258
|
-
);
|
|
259
|
-
|
|
260
|
-
if (isStream && response.ok && response.body) {
|
|
261
|
-
const ct =
|
|
262
|
-
response.headers.get("content-type") || "text/event-stream";
|
|
263
|
-
res.writeHead(response.status, {
|
|
264
|
-
"content-type": ct,
|
|
265
|
-
"cache-control": "no-cache, no-transform",
|
|
266
|
-
connection: "keep-alive",
|
|
267
|
-
"x-accel-buffering": "no",
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
"content-type"
|
|
321
|
-
"
|
|
322
|
-
|
|
323
|
-
"
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
res.
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
}
|
|
451
|
-
res.
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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, resolveCanonicalModelId } from "./models.ts";
|
|
27
|
+
// normalize removed — host pi-ai already normalizes thinking/reasoning before proxy
|
|
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
|
+
* Clamps reasoning_effort for upstream models with strict non-standard enums
|
|
92
|
+
* (e.g. OpenCode x-preview strictly requires 'low', 'high', or 'max' and rejects 'medium' with 400).
|
|
93
|
+
*/
|
|
94
|
+
function sanitizeReasoningForModel(bodyObj: Record<string, unknown>): void {
|
|
95
|
+
const model = String(bodyObj.model || "").toLowerCase();
|
|
96
|
+
if (model.includes("x-preview")) {
|
|
97
|
+
const effort = String(bodyObj.reasoning_effort || "").toLowerCase();
|
|
98
|
+
if (effort === "medium") {
|
|
99
|
+
bodyObj.reasoning_effort = "high";
|
|
100
|
+
} else if (effort === "minimal") {
|
|
101
|
+
bodyObj.reasoning_effort = "low";
|
|
102
|
+
} else if (!effort || effort === "off" || effort === "none") {
|
|
103
|
+
bodyObj.reasoning_effort = "low";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Probe whether an existing pi-freeflow proxy daemon is running and responsive on a given port.
|
|
109
|
+
*/
|
|
110
|
+
export async function isProxyAlive(port: number): Promise<boolean> {
|
|
111
|
+
try {
|
|
112
|
+
const res = await fetch(`http://${HOST}:${port}/v1/models`, {
|
|
113
|
+
signal: AbortSignal.timeout(500),
|
|
114
|
+
});
|
|
115
|
+
const ct = res.headers.get("content-type") || "";
|
|
116
|
+
return res.ok && ct.includes("application/json");
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Start the local HTTP proxy daemon.
|
|
124
|
+
*
|
|
125
|
+
* Implements master/worker single-port reuse: if port 18080 is already held by a live
|
|
126
|
+
* parent or sibling OMP session, resolves immediately with { server: null, port: 18080 }.
|
|
127
|
+
*/
|
|
128
|
+
export function startProxy(
|
|
129
|
+
overridePort?: number,
|
|
130
|
+
): Promise<{ server: http.Server | null; port: number }> {
|
|
131
|
+
const basePort = overridePort ?? PORT;
|
|
132
|
+
|
|
133
|
+
const server = http.createServer((req, res) => {
|
|
134
|
+
const clientIP = getClientIP(req);
|
|
135
|
+
const reqId = randomUUID().slice(0, 8);
|
|
136
|
+
if (isDebugEnabled()) {
|
|
137
|
+
log(
|
|
138
|
+
"debug",
|
|
139
|
+
`incoming ${req.method} ${req.url} from ${clientIP}`,
|
|
140
|
+
{ ip: clientIP, method: req.method, url: req.url },
|
|
141
|
+
reqId,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!ALLOWED_METHODS.has(req.method ?? "")) {
|
|
146
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
147
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (req.method === "OPTIONS") {
|
|
152
|
+
res.writeHead(204, {
|
|
153
|
+
"access-control-allow-origin": "*",
|
|
154
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
155
|
+
"access-control-max-age": "86400",
|
|
156
|
+
});
|
|
157
|
+
res.end();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Serve ONLY our registered free models. Never forward /v1/models to upstream
|
|
162
|
+
// to prevent paid/proprietary upstream models from leaking into the model picker.
|
|
163
|
+
// Use pathname check so /v1/models?query variants are also guarded (no leak).
|
|
164
|
+
let reqPathname: string | null = null;
|
|
165
|
+
try {
|
|
166
|
+
reqPathname = new URL(req.url ?? "/", `http://${HOST}`).pathname;
|
|
167
|
+
} catch {}
|
|
168
|
+
if (req.method === "GET" && (reqPathname === "/v1/models" || reqPathname === "/v1/models/")) {
|
|
169
|
+
const alive = getAliveCatalog();
|
|
170
|
+
const body = JSON.stringify({
|
|
171
|
+
object: "list",
|
|
172
|
+
data: alive.map((m) => ({
|
|
173
|
+
id: m.id,
|
|
174
|
+
object: "model",
|
|
175
|
+
created: 0,
|
|
176
|
+
owned_by: m.source === "kilo" ? "kilocode" : "opencode",
|
|
177
|
+
})),
|
|
178
|
+
});
|
|
179
|
+
res.writeHead(200, {
|
|
180
|
+
"content-type": "application/json",
|
|
181
|
+
"content-length": Buffer.byteLength(body),
|
|
182
|
+
});
|
|
183
|
+
res.end(body);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const target = validatePath(req.url ?? "/");
|
|
188
|
+
if (!target) {
|
|
189
|
+
res.writeHead(403, { "content-type": "application/json" });
|
|
190
|
+
res.end(JSON.stringify({ error: "forbidden" }));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Buffer request body to inspect model ID for upstream routing
|
|
195
|
+
const bodyChunks: Buffer[] = [];
|
|
196
|
+
req.on("error", (err) => {
|
|
197
|
+
log(
|
|
198
|
+
"warn",
|
|
199
|
+
"client request error during body buffering",
|
|
200
|
+
{ error: String(err) },
|
|
201
|
+
reqId,
|
|
202
|
+
);
|
|
203
|
+
if (!res.headersSent) {
|
|
204
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
205
|
+
}
|
|
206
|
+
res.end(JSON.stringify({ error: "bad request" }));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
req.on("data", (chunk: Buffer) => bodyChunks.push(chunk));
|
|
210
|
+
|
|
211
|
+
req.on("end", async () => {
|
|
212
|
+
const bodyStr = Buffer.concat(bodyChunks).toString();
|
|
213
|
+
let isKilo = false;
|
|
214
|
+
let parsedBody: Record<string, unknown> | null = null;
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
parsedBody = JSON.parse(bodyStr);
|
|
218
|
+
if (typeof parsedBody?.model === "string") {
|
|
219
|
+
const canonical = resolveCanonicalModelId(parsedBody.model);
|
|
220
|
+
parsedBody.model = canonical;
|
|
221
|
+
if (KILO_MODEL_IDS.has(canonical)) {
|
|
222
|
+
isKilo = true;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
} catch {}
|
|
226
|
+
|
|
227
|
+
const upstream: Upstream = isKilo ? "kilo" : "opencode";
|
|
228
|
+
const isStream = parsedBody?.stream === true;
|
|
229
|
+
|
|
230
|
+
// Seamless sub-agent rate-limit: when relay pool is active, bypass
|
|
231
|
+
// local per-IP quota (127.0.0.1 shared by all subagents) — upstream
|
|
232
|
+
// quota is per-egress-IP and relayFetch already rolls on 429 across
|
|
233
|
+
// 7 candidates until a response succeeds. Without this, parallel
|
|
234
|
+
// subagents sharing the daemon would hit local 429 before relay failover.
|
|
235
|
+
const relayPreview = getActiveRelayState();
|
|
236
|
+
const willUseRelay = relayPreview.enabled && Boolean(relayPreview.url || relayPreview.relays.length > 0);
|
|
237
|
+
if (!willUseRelay && !checkRateLimit(clientIP, upstream)) {
|
|
238
|
+
res.writeHead(429, { "content-type": "application/json" });
|
|
239
|
+
res.end(JSON.stringify({ error: "rate limit exceeded" }));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
if (isKilo && parsedBody) {
|
|
245
|
+
const kiloBodyObj = structuredClone(parsedBody);
|
|
246
|
+
const response = await relayFetch(
|
|
247
|
+
KILO_CHAT_URL,
|
|
248
|
+
{
|
|
249
|
+
method: "POST",
|
|
250
|
+
headers: {
|
|
251
|
+
"Content-Type": "application/json",
|
|
252
|
+
Authorization: "Bearer kilo-free",
|
|
253
|
+
},
|
|
254
|
+
body: JSON.stringify(kiloBodyObj),
|
|
255
|
+
signal: AbortSignal.timeout(300_000),
|
|
256
|
+
},
|
|
257
|
+
reqId,
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
if (isStream && response.ok && response.body) {
|
|
261
|
+
const ct =
|
|
262
|
+
response.headers.get("content-type") || "text/event-stream";
|
|
263
|
+
res.writeHead(response.status, {
|
|
264
|
+
"content-type": ct,
|
|
265
|
+
"cache-control": "no-cache, no-transform",
|
|
266
|
+
connection: "keep-alive",
|
|
267
|
+
"x-accel-buffering": "no",
|
|
268
|
+
});
|
|
269
|
+
// Kilo is fetched directly (not via the relay pool), so pass undefined:
|
|
270
|
+
// attributing kilo-side stream failures to an unrelated opencode relay
|
|
271
|
+
// would mark a healthy relay as failed.
|
|
272
|
+
pipeUpstreamStream(
|
|
273
|
+
Readable.fromWeb(
|
|
274
|
+
response.body as unknown as WebReadableStream,
|
|
275
|
+
),
|
|
276
|
+
res,
|
|
277
|
+
req,
|
|
278
|
+
reqId,
|
|
279
|
+
undefined,
|
|
280
|
+
);
|
|
281
|
+
} else {
|
|
282
|
+
const data = await response.text();
|
|
283
|
+
const ct =
|
|
284
|
+
response.headers.get("content-type") || "application/json";
|
|
285
|
+
res.writeHead(response.status, { "content-type": ct });
|
|
286
|
+
res.end(data);
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
// OpenCode routing — relay when enabled, else direct upstream
|
|
290
|
+
const relayState = getActiveRelayState();
|
|
291
|
+
const shouldUseRelay =
|
|
292
|
+
relayState.mode !== "off" &&
|
|
293
|
+
relayState.enabled !== false &&
|
|
294
|
+
Boolean(relayState.url || (relayState.relays && relayState.relays.length > 0));
|
|
295
|
+
if (shouldUseRelay) {
|
|
296
|
+
const fullUrl = `${UPSTREAM_OPENCODE}${req.url ?? "/"}`;
|
|
297
|
+
const activeHost = relayState.url
|
|
298
|
+
? new URL(relayState.url).host
|
|
299
|
+
: "opencode.ai";
|
|
300
|
+
const relayHeaders = sanitizeHeaders(req.headers, activeHost);
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
if (parsedBody) {
|
|
304
|
+
const relayBodyObj = structuredClone(parsedBody);
|
|
305
|
+
sanitizeReasoningForModel(relayBodyObj as Record<string, unknown>);
|
|
306
|
+
const relayBody = Buffer.from(JSON.stringify(relayBodyObj));
|
|
307
|
+
const response = await relayFetch(
|
|
308
|
+
fullUrl,
|
|
309
|
+
{
|
|
310
|
+
method: req.method || "POST",
|
|
311
|
+
headers: relayHeaders,
|
|
312
|
+
body: relayBody,
|
|
313
|
+
signal: AbortSignal.timeout(300_000),
|
|
314
|
+
},
|
|
315
|
+
reqId,
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
if (isStream && response.ok && response.body) {
|
|
319
|
+
const ct =
|
|
320
|
+
response.headers.get("content-type") ||
|
|
321
|
+
"text/event-stream";
|
|
322
|
+
res.writeHead(response.status, {
|
|
323
|
+
"content-type": ct,
|
|
324
|
+
"cache-control": "no-cache, no-transform",
|
|
325
|
+
connection: "keep-alive",
|
|
326
|
+
"x-accel-buffering": "no",
|
|
327
|
+
});
|
|
328
|
+
pipeUpstreamStream(
|
|
329
|
+
Readable.fromWeb(
|
|
330
|
+
response.body as unknown as WebReadableStream,
|
|
331
|
+
),
|
|
332
|
+
res,
|
|
333
|
+
req,
|
|
334
|
+
reqId,
|
|
335
|
+
relayState.url,
|
|
336
|
+
);
|
|
337
|
+
} else {
|
|
338
|
+
const data = await response.text();
|
|
339
|
+
const ct =
|
|
340
|
+
response.headers.get("content-type") ||
|
|
341
|
+
"application/json";
|
|
342
|
+
res.writeHead(response.status, { "content-type": ct });
|
|
343
|
+
res.end(data);
|
|
344
|
+
}
|
|
345
|
+
return; // relay handled successfully
|
|
346
|
+
}
|
|
347
|
+
} catch (e) {
|
|
348
|
+
log(
|
|
349
|
+
"warn",
|
|
350
|
+
"opencode relay failed, falling back to direct upstream",
|
|
351
|
+
{ error: String(e) },
|
|
352
|
+
reqId,
|
|
353
|
+
);
|
|
354
|
+
if (res.headersSent) return; // cannot recover mid-stream
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Direct path — with debug trace and thinking-aware normalization
|
|
359
|
+
let directBody = Buffer.concat(bodyChunks);
|
|
360
|
+
if (parsedBody) {
|
|
361
|
+
const directBodyObj = structuredClone(parsedBody);
|
|
362
|
+
sanitizeReasoningForModel(directBodyObj as Record<string, unknown>);
|
|
363
|
+
directBody = Buffer.from(JSON.stringify(directBodyObj));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (isDebugEnabled()) {
|
|
367
|
+
log(
|
|
368
|
+
"debug",
|
|
369
|
+
`direct upstream ${target.hostname}${target.pathname} (${directBody.length}B)`,
|
|
370
|
+
{ model: parsedBody?.model, isKilo },
|
|
371
|
+
reqId,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const fwd = sanitizeHeaders(req.headers, target.hostname);
|
|
376
|
+
if (directBody.length > 0) {
|
|
377
|
+
fwd["content-length"] = String(directBody.byteLength);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const proxy = https.request(
|
|
381
|
+
{
|
|
382
|
+
method: req.method,
|
|
383
|
+
hostname: target.hostname,
|
|
384
|
+
port: 443,
|
|
385
|
+
path: target.pathname + target.search,
|
|
386
|
+
headers: fwd,
|
|
387
|
+
},
|
|
388
|
+
(upstream) => {
|
|
389
|
+
const outHeaders: Record<string, string> = {};
|
|
390
|
+
for (const h of [
|
|
391
|
+
"content-type",
|
|
392
|
+
"cache-control",
|
|
393
|
+
"x-request-id",
|
|
394
|
+
]) {
|
|
395
|
+
const val = upstream.headers[h];
|
|
396
|
+
if (typeof val === "string") outHeaders[h] = val;
|
|
397
|
+
}
|
|
398
|
+
outHeaders["x-content-type-options"] = "nosniff";
|
|
399
|
+
res.writeHead(upstream.statusCode ?? 502, outHeaders);
|
|
400
|
+
if (isStream) {
|
|
401
|
+
pipeUpstreamStream(upstream, res, req, reqId, "direct");
|
|
402
|
+
} else {
|
|
403
|
+
upstream.on("error", (streamErr) => {
|
|
404
|
+
log(
|
|
405
|
+
"error",
|
|
406
|
+
"upstream stream error in direct proxy",
|
|
407
|
+
{ error: String(streamErr) },
|
|
408
|
+
reqId,
|
|
409
|
+
);
|
|
410
|
+
if (!res.writableEnded) res.end();
|
|
411
|
+
});
|
|
412
|
+
upstream.pipe(res);
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
proxy.on("error", (proxyErr) => {
|
|
418
|
+
log(
|
|
419
|
+
"error",
|
|
420
|
+
"proxy socket error",
|
|
421
|
+
{ error: String(proxyErr) },
|
|
422
|
+
reqId,
|
|
423
|
+
);
|
|
424
|
+
if (!res.headersSent) {
|
|
425
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
426
|
+
res.end(JSON.stringify({ error: "upstream error" }));
|
|
427
|
+
} else if (!res.writableEnded) {
|
|
428
|
+
res.end();
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
proxy.setTimeout(300_000, () => {
|
|
433
|
+
proxy.destroy(new Error("timeout"));
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Premature client disconnect guard.
|
|
437
|
+
// Node >= 19 emits req 'close' right after the request body 'end',
|
|
438
|
+
// so destroying on req close/aborted kills every healthy upstream
|
|
439
|
+
// socket milliseconds after creation. Only tear down when the
|
|
440
|
+
// client response connection actually drops mid-flight.
|
|
441
|
+
const destroyIfClientGone = () => {
|
|
442
|
+
if (!res.writableEnded && !proxy.destroyed) proxy.destroy();
|
|
443
|
+
};
|
|
444
|
+
req.on("error", destroyIfClientGone);
|
|
445
|
+
res.on("close", destroyIfClientGone);
|
|
446
|
+
|
|
447
|
+
proxy.end(directBody);
|
|
448
|
+
}
|
|
449
|
+
} catch (err) {
|
|
450
|
+
log("error", "proxy error", { error: String(err) }, reqId);
|
|
451
|
+
if (!res.headersSent) {
|
|
452
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
453
|
+
}
|
|
454
|
+
res.end(JSON.stringify({ error: "internal error" }));
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
return new Promise<{ server: http.Server | null; port: number }>(
|
|
460
|
+
(resolve, reject) => {
|
|
461
|
+
let attempt = 0;
|
|
462
|
+
let settled = false;
|
|
463
|
+
|
|
464
|
+
const tryListen = async (port: number) => {
|
|
465
|
+
server.once("error", async (err: NodeJS.ErrnoException) => {
|
|
466
|
+
if (settled) return;
|
|
467
|
+
if (err.code === "EADDRINUSE") {
|
|
468
|
+
// Re-check if the base port is alive (attached master race)
|
|
469
|
+
if (await isProxyAlive(basePort)) {
|
|
470
|
+
settled = true;
|
|
471
|
+
log(
|
|
472
|
+
"info",
|
|
473
|
+
`attached to running proxy on http://${HOST}:${basePort}`,
|
|
474
|
+
);
|
|
475
|
+
resolve({ server: null, port: basePort });
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (attempt < 20) {
|
|
479
|
+
attempt++;
|
|
480
|
+
log("warn", `port ${port} taken — trying ${port + 1}`);
|
|
481
|
+
tryListen(port + 1);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
settled = true;
|
|
486
|
+
log("error", "server error", { code: err.code, message: err.message });
|
|
487
|
+
reject(err);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
server.listen(port, HOST, () => {
|
|
491
|
+
if (settled) return;
|
|
492
|
+
settled = true;
|
|
493
|
+
const addr = server.address();
|
|
494
|
+
const realPort = addr && typeof addr === "object" ? addr.port : port;
|
|
495
|
+
log("info", `proxy listening on http://${HOST}:${realPort}`);
|
|
496
|
+
resolve({ server, port: realPort });
|
|
497
|
+
});
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
tryListen(basePort);
|
|
501
|
+
},
|
|
502
|
+
);
|
|
503
|
+
}
|