pi-freeflow 1.4.2 → 1.4.4

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