pi-freeflow 1.0.9 → 1.1.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.
Files changed (2) hide show
  1. package/extensions/index.ts +172 -139
  2. package/package.json +1 -1
@@ -88,13 +88,15 @@ function loadRelayState(): RelayState {
88
88
  try {
89
89
  const s = JSON.parse(fs.readFileSync(RELAY_STATE_FILE, "utf8"));
90
90
  const relays: KnownRelay[] = Array.isArray(s?.relays) ? s.relays : [];
91
+ // Auto-on by default if saved relays exist, unless explicitly set to false
92
+ const enabled = s?.enabled !== undefined ? Boolean(s.enabled) : relays.length > 0;
91
93
  return {
92
- enabled: Boolean(s?.enabled),
93
- url: typeof s?.url === "string" ? s.url.trim() : "",
94
+ enabled,
95
+ url: typeof s?.url === "string" ? s.url.trim() : (relays[0]?.url || ""),
94
96
  relays,
95
97
  };
96
98
  } catch {
97
- return { enabled: false, url: "", relays: [] };
99
+ return { enabled: true, url: "", relays: [] };
98
100
  }
99
101
  }
100
102
  function saveRelayState(s: RelayState): void {
@@ -196,12 +198,14 @@ async function relayFetch(
196
198
  const targetUrl = candidates[i];
197
199
  const attemptStart = Date.now();
198
200
  try {
199
- const targetHost = new URL(targetUrl).host;
201
+ let targetHost = "opencode.ai";
202
+ try {
203
+ if (targetUrl) targetHost = new URL(targetUrl).host;
204
+ } catch {}
200
205
  const headers = new Headers(opts.headers);
201
206
  headers.set("x-relay-target", relayTarget);
202
207
  headers.set("x-relay-path", relayPath);
203
208
  headers.set("host", targetHost);
204
-
205
209
  const signal = opts.signal || AbortSignal.timeout(300_000);
206
210
  const res = await fetch(targetUrl, { ...opts, headers, signal });
207
211
  const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
@@ -659,7 +663,8 @@ const KILO_MODELS: ModelDef[] = [
659
663
  },
660
664
  ];
661
665
  const KILO_MODEL_IDS = new Set(KILO_MODELS.map((m) => m.id));
662
-
666
+ const ALL_MODELS = [...KNOWN_MODELS, ...KILO_MODELS];
667
+ const MODEL_MAP = new Map(ALL_MODELS.map((m) => [m.id, m]));
663
668
  // ── Whitelists ─────────────────────────────────────────────────────
664
669
  const ALLOWED_PATH_PATTERN = /^\/v1\/[a-zA-Z0-9/_.,\-?&=]*$/;
665
670
  const PATH_TRAVERSAL_PATTERN = /\.\./;
@@ -910,12 +915,18 @@ function sanitizeHeaders(
910
915
  function normalizeRequestBody(
911
916
  body: Record<string, unknown>,
912
917
  isRelay = false,
918
+ isKilo = false,
913
919
  ): Record<string, unknown> {
914
- // 1. Tool choice normalization (OpenCode Zen only supports "auto" or undefined)
920
+ // 1. Tool choice & empty tools normalization
921
+ if (Array.isArray(body.tools) && body.tools.length === 0) {
922
+ delete body.tools;
923
+ delete body.tool_choice;
924
+ }
915
925
  if (body.tool_choice === "none") {
916
926
  delete body.tool_choice;
917
927
  delete body.tools;
918
- } else if (body.tool_choice && body.tool_choice !== "auto") {
928
+ } else if (!isKilo && body.tool_choice && body.tool_choice !== "auto") {
929
+ // OpenCode Zen only supports "auto" or undefined
919
930
  body.tool_choice = "auto";
920
931
  }
921
932
 
@@ -947,11 +958,17 @@ function normalizeRequestBody(
947
958
  }
948
959
  }
949
960
 
950
- // 3. Max & Min tokens clamping (OpenCode Zen requires min 16; Vercel relay caps at 64k)
961
+ // 3. Max & Min tokens clamping (model-specific clamp + Vercel relay clamp)
962
+ const modelId = typeof body.model === "string" ? body.model : "";
963
+ const modelDef = MODEL_MAP.get(modelId);
964
+ const modelMax = modelDef?.maxTokens ?? RELAY_MAX_TOKENS;
965
+
951
966
  const clampTokens = (val: number): number => {
952
- if (val < 16) return 16;
953
- if (isRelay && val > RELAY_MAX_TOKENS) return RELAY_MAX_TOKENS;
954
- return val;
967
+ let clamped = val;
968
+ if (!isKilo && clamped < 16) clamped = 16;
969
+ if (clamped > modelMax) clamped = modelMax;
970
+ if (isRelay && clamped > RELAY_MAX_TOKENS) clamped = RELAY_MAX_TOKENS;
971
+ return clamped;
955
972
  };
956
973
 
957
974
  if (typeof body.max_tokens === "number") {
@@ -1027,7 +1044,7 @@ function pipeUpstreamStream(
1027
1044
  // ── Start local proxy ──────────────────────────────────────────────
1028
1045
  function startProxy(
1029
1046
  overridePort?: number,
1030
- ): Promise<{ server: http.Server; port: number }> {
1047
+ ): Promise<{ server: http.Server | null; port: number }> {
1031
1048
  const basePort = overridePort ?? PORT;
1032
1049
 
1033
1050
  const server = http.createServer((req, res) => {
@@ -1081,6 +1098,11 @@ function startProxy(
1081
1098
 
1082
1099
  // Read body to detect model for routing
1083
1100
  const bodyChunks: Buffer[] = [];
1101
+ req.on("error", (err) => {
1102
+ log("warn", "client request error during body buffering", { error: String(err) });
1103
+ if (!res.headersSent) res.writeHead(400, { "content-type": "application/json" });
1104
+ res.end(JSON.stringify({ error: "bad request" }));
1105
+ });
1084
1106
  req.on("data", (chunk: Buffer) => bodyChunks.push(chunk));
1085
1107
  req.on("end", async () => {
1086
1108
  const bodyStr = Buffer.concat(bodyChunks).toString();
@@ -1104,7 +1126,7 @@ function startProxy(
1104
1126
  // KiloCode gateway routing (free models are keyless)
1105
1127
  const isStream = parsedBody.stream === true;
1106
1128
  const kiloBodyObj = structuredClone(parsedBody);
1107
- normalizeRequestBody(kiloBodyObj, true);
1129
+ normalizeRequestBody(kiloBodyObj, true, isKilo);
1108
1130
  const response = await relayFetch(KILO_CHAT_URL, {
1109
1131
  method: "POST",
1110
1132
  headers: {
@@ -1142,7 +1164,7 @@ function startProxy(
1142
1164
  // round-robin: any saved relay qualifies, not just single url
1143
1165
  if (relayState.enabled && (relayState.url || relayState.relays.length > 0)) {
1144
1166
  const fullUrl = `${UPSTREAM_OPENCODE}${req.url ?? "/"}`;
1145
- const activeHost = new URL(relayState.url || DEFAULT_RELAY_URL).host;
1167
+ const activeHost = relayState.url ? new URL(relayState.url).host : "opencode.ai";
1146
1168
  const relayHeaders = sanitizeHeaders(
1147
1169
  req.headers,
1148
1170
  activeHost,
@@ -1153,7 +1175,7 @@ function startProxy(
1153
1175
  : undefined;
1154
1176
  if (relayBody && parsedBody) {
1155
1177
  const relayBodyObj = structuredClone(parsedBody);
1156
- normalizeRequestBody(relayBodyObj, true);
1178
+ normalizeRequestBody(relayBodyObj, true, isKilo);
1157
1179
  relayBody = Buffer.from(JSON.stringify(relayBodyObj));
1158
1180
  }
1159
1181
  const response = await relayFetch(fullUrl, {
@@ -1190,124 +1212,133 @@ function startProxy(
1190
1212
  return; // relay handled the response
1191
1213
  } catch (e) {
1192
1214
  log("warn", "opencode relay failed, falling back to direct", {
1193
- error: String(e),
1194
- });
1195
- if (res.headersSent) return; // can't recover mid-stream
1196
- }
1197
- }
1198
- // direct path (existing, untouched)
1199
- let directBody = Buffer.concat(bodyChunks);
1200
- if (parsedBody) {
1201
- const directBodyObj = structuredClone(parsedBody);
1202
- normalizeRequestBody(directBodyObj, false);
1203
- directBody = Buffer.from(JSON.stringify(directBodyObj));
1204
- }
1205
- const fwd = sanitizeHeaders(req.headers, target.hostname);
1206
- if (directBody.length > 0) {
1207
- fwd["content-length"] = String(directBody.byteLength);
1208
- }
1209
- const proxy = https.request(
1210
- {
1211
- method: req.method,
1212
- hostname: target.hostname,
1213
- port: 443,
1214
- path: target.pathname + target.search,
1215
- headers: fwd,
1216
- },
1217
- (upstream) => {
1218
- const outHeaders: Record<string, string> = {};
1219
- for (const h of [
1220
- "content-type",
1221
- "cache-control",
1222
- "x-request-id",
1223
- ]) {
1224
- const val = upstream.headers[h];
1225
- if (typeof val === "string") outHeaders[h] = val;
1226
- }
1227
- outHeaders["x-content-type-options"] = "nosniff";
1228
- res.writeHead(upstream.statusCode ?? 502, outHeaders);
1229
- upstream.pipe(res);
1230
- },
1231
- );
1232
- proxy.on("error", () => {
1233
- if (!res.headersSent) {
1234
- res.writeHead(502, { "content-type": "application/json" });
1235
- res.end(JSON.stringify({ error: "upstream error" }));
1236
- } else if (!res.writableEnded) {
1237
- res.end();
1238
- }
1239
- });
1240
- proxy.setTimeout(300_000, () => {
1241
- proxy.destroy(new Error("timeout"));
1242
- });
1243
- req.on("aborted", () => {
1244
- if (!proxy.destroyed) proxy.destroy();
1245
- });
1246
- req.on("close", () => {
1247
- if (!proxy.destroyed) proxy.destroy();
1248
- });
1249
- res.on("close", () => {
1250
- if (!proxy.destroyed) proxy.destroy();
1251
- });
1252
- // ponytail: body already buffered in bodyChunks above for model routing;
1253
- // req is drained so pipe() would send an empty body → upstream hang → 502.
1254
- proxy.end(directBody);
1255
- }
1256
- } catch (err) {
1257
- log("error", "proxy error", { error: String(err) });
1258
- if (!res.headersSent)
1259
- res.writeHead(502, { "content-type": "application/json" });
1260
- res.end(JSON.stringify({ error: "internal error" }));
1261
- }
1262
- });
1263
- });
1264
-
1265
- return new Promise((resolve, reject) => {
1266
- // ponytail: auto-bump to next free port so multiple pi sessions on one
1267
- // machine don't fight over 18080. cap at 20 to avoid infinite scan.
1268
- // use server.address() for the real port: a failed listen()'s callback
1269
- // still fires on the next successful listen, so the closure `port` is stale.
1270
- let attempt = 0;
1271
- let settled = false;
1272
- const tryListen = (port: number) => {
1273
- server.once("error", (err: NodeJS.ErrnoException) => {
1274
- if (settled) return;
1275
- if (err.code === "EADDRINUSE" && attempt < 20) {
1276
- attempt++;
1277
- log("warn", `port ${port} taken trying ${port + 1}`);
1278
- tryListen(port + 1);
1279
- return;
1280
- }
1281
- settled = true;
1282
- log("error", "server error", { code: err.code, message: err.message });
1283
- reject(err);
1284
- });
1285
- server.listen(port, HOST, () => {
1286
- if (settled) return;
1287
- settled = true;
1288
- const addr = server.address();
1289
- const realPort = addr && typeof addr === "object" ? addr.port : port;
1290
- log("info", `proxy listening on http://${HOST}:${realPort}`);
1291
- resolve({ server, port: realPort });
1292
- });
1293
- };
1294
- tryListen(basePort);
1295
- });
1296
- }
1297
-
1298
- // Probe whether an existing freeflow proxy is already running on a port
1299
- async function isProxyAlive(port: number): Promise<boolean> {
1300
- try {
1301
- const res = await fetch(`http://${HOST}:${port}/v1/models`, {
1302
- signal: AbortSignal.timeout(500),
1303
- });
1304
- const ct = res.headers.get("content-type") || "";
1305
- return res.ok && ct.includes("application/json");
1306
- } catch {
1307
- return false;
1308
- }
1309
- }
1310
-
1215
+ error: String(e),
1216
+ });
1217
+ if (res.headersSent) return; // can't recover mid-stream
1218
+ }
1219
+ }
1220
+ // direct path (existing, untouched)
1221
+ let directBody = Buffer.concat(bodyChunks);
1222
+ if (parsedBody) {
1223
+ const directBodyObj = structuredClone(parsedBody);
1224
+ normalizeRequestBody(directBodyObj, false, isKilo);
1225
+ directBody = Buffer.from(JSON.stringify(directBodyObj));
1226
+ }
1227
+ const fwd = sanitizeHeaders(req.headers, target.hostname);
1228
+ if (directBody.length > 0) {
1229
+ fwd["content-length"] = String(directBody.byteLength);
1230
+ }
1231
+ const proxy = https.request(
1232
+ {
1233
+ method: req.method,
1234
+ hostname: target.hostname,
1235
+ port: 443,
1236
+ path: target.pathname + target.search,
1237
+ headers: fwd,
1238
+ },
1239
+ (upstream) => {
1240
+ const outHeaders: Record<string, string> = {};
1241
+ for (const h of [
1242
+ "content-type",
1243
+ "cache-control",
1244
+ "x-request-id",
1245
+ ]) {
1246
+ const val = upstream.headers[h];
1247
+ if (typeof val === "string") outHeaders[h] = val;
1248
+ }
1249
+ outHeaders["x-content-type-options"] = "nosniff";
1250
+ res.writeHead(upstream.statusCode ?? 502, outHeaders);
1251
+ upstream.on("error", (streamErr) => {
1252
+ log("error", "upstream stream error in direct proxy", { error: String(streamErr) });
1253
+ if (!res.writableEnded) res.end();
1254
+ });
1255
+ upstream.pipe(res);
1256
+ },
1257
+ );
1258
+ proxy.on("error", (proxyErr) => {
1259
+ log("error", "proxy socket error", { error: String(proxyErr) });
1260
+ if (!res.headersSent) {
1261
+ res.writeHead(502, { "content-type": "application/json" });
1262
+ res.end(JSON.stringify({ error: "upstream error" }));
1263
+ } else if (!res.writableEnded) {
1264
+ res.end();
1265
+ }
1266
+ });
1267
+ proxy.setTimeout(300_000, () => {
1268
+ proxy.destroy(new Error("timeout"));
1269
+ });
1270
+ req.on("aborted", () => {
1271
+ if (!proxy.destroyed) proxy.destroy();
1272
+ });
1273
+ req.on("close", () => {
1274
+ if (!proxy.destroyed) proxy.destroy();
1275
+ });
1276
+ res.on("close", () => {
1277
+ if (!proxy.destroyed) proxy.destroy();
1278
+ });
1279
+ // ponytail: body already buffered in bodyChunks above for model routing;
1280
+ // req is drained so pipe() would send an empty body → upstream hang → 502.
1281
+ proxy.end(directBody);
1282
+ }
1283
+ } catch (err) {
1284
+ log("error", "proxy error", { error: String(err) });
1285
+ if (!res.headersSent)
1286
+ res.writeHead(502, { "content-type": "application/json" });
1287
+ res.end(JSON.stringify({ error: "internal error" }));
1288
+ }
1289
+ });
1290
+ });
1291
+
1292
+ return new Promise((resolve, reject) => {
1293
+ let attempt = 0;
1294
+ let settled = false;
1295
+ const tryListen = async (port: number) => {
1296
+ server.once("error", async (err: NodeJS.ErrnoException) => {
1297
+ if (settled) return;
1298
+ if (err.code === "EADDRINUSE") {
1299
+ // Re-check if the base port is alive (attached master race)
1300
+ if (await isProxyAlive(basePort)) {
1301
+ settled = true;
1302
+ log("info", `attached to running proxy on http://${HOST}:${basePort}`);
1303
+ resolve({ server: null, port: basePort });
1304
+ return;
1305
+ }
1306
+ if (attempt < 20) {
1307
+ attempt++;
1308
+ log("warn", `port ${port} taken — trying ${port + 1}`);
1309
+ tryListen(port + 1);
1310
+ return;
1311
+ }
1312
+ }
1313
+ settled = true;
1314
+ log("error", "server error", { code: err.code, message: err.message });
1315
+ reject(err);
1316
+ });
1317
+ server.listen(port, HOST, () => {
1318
+ if (settled) return;
1319
+ settled = true;
1320
+ const addr = server.address();
1321
+ const realPort = addr && typeof addr === "object" ? addr.port : port;
1322
+ log("info", `proxy listening on http://${HOST}:${realPort}`);
1323
+ resolve({ server, port: realPort });
1324
+ });
1325
+ };
1326
+ tryListen(basePort);
1327
+ });
1328
+ }
1329
+
1330
+ // Probe whether an existing freeflow proxy is already running on a port
1331
+ async function isProxyAlive(port: number): Promise<boolean> {
1332
+ try {
1333
+ const res = await fetch(`http://${HOST}:${port}/v1/models`, {
1334
+ signal: AbortSignal.timeout(500),
1335
+ });
1336
+ const ct = res.headers.get("content-type") || "";
1337
+ return res.ok && ct.includes("application/json");
1338
+ } catch {
1339
+ return false;
1340
+ }
1341
+ }
1311
1342
  // ── Main extension ─────────────────────────────────────────────────
1312
1343
  export default async function (pi: ExtensionAPI) {
1313
1344
  log("info", "extension loading...");
@@ -1664,10 +1695,12 @@ export default async function (pi: ExtensionAPI) {
1664
1695
  );
1665
1696
  });
1666
1697
 
1667
- pi.on("session_shutdown", () => {
1668
- log("info", "shutting down proxy...");
1669
- server.close();
1670
- rateLimitMap.clear();
1671
- log("info", "shutdown complete");
1698
+ pi.on?.("session_shutdown", () => {
1699
+ if (server) {
1700
+ log("info", "shutting down proxy...");
1701
+ server.close();
1702
+ rateLimitMap.clear();
1703
+ log("info", "shutdown complete");
1704
+ }
1672
1705
  });
1673
1706
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.0.9",
4
+ "version": "1.1.1",
5
5
  "description": "Personal multi-cloud rolling fallback relay for OpenCode Zen and KiloCode models in OMP/Pi",
6
6
  "keywords": [
7
7
  "pi-package",