pi-freeflow 1.1.0 → 1.1.2

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 +178 -140
  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 {
@@ -158,7 +160,8 @@ function isRetriableStatus(status: number): boolean {
158
160
  status === 408 ||
159
161
  status === 402 ||
160
162
  status === 403 ||
161
- status === 500
163
+ status === 500 ||
164
+ status === 400
162
165
  );
163
166
  }
164
167
 
@@ -196,12 +199,14 @@ async function relayFetch(
196
199
  const targetUrl = candidates[i];
197
200
  const attemptStart = Date.now();
198
201
  try {
199
- const targetHost = new URL(targetUrl).host;
202
+ let targetHost = "opencode.ai";
203
+ try {
204
+ if (targetUrl) targetHost = new URL(targetUrl).host;
205
+ } catch {}
200
206
  const headers = new Headers(opts.headers);
201
207
  headers.set("x-relay-target", relayTarget);
202
208
  headers.set("x-relay-path", relayPath);
203
209
  headers.set("host", targetHost);
204
-
205
210
  const signal = opts.signal || AbortSignal.timeout(300_000);
206
211
  const res = await fetch(targetUrl, { ...opts, headers, signal });
207
212
  const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
@@ -659,7 +664,8 @@ const KILO_MODELS: ModelDef[] = [
659
664
  },
660
665
  ];
661
666
  const KILO_MODEL_IDS = new Set(KILO_MODELS.map((m) => m.id));
662
-
667
+ const ALL_MODELS = [...KNOWN_MODELS, ...KILO_MODELS];
668
+ const MODEL_MAP = new Map(ALL_MODELS.map((m) => [m.id, m]));
663
669
  // ── Whitelists ─────────────────────────────────────────────────────
664
670
  const ALLOWED_PATH_PATTERN = /^\/v1\/[a-zA-Z0-9/_.,\-?&=]*$/;
665
671
  const PATH_TRAVERSAL_PATTERN = /\.\./;
@@ -910,20 +916,29 @@ function sanitizeHeaders(
910
916
  function normalizeRequestBody(
911
917
  body: Record<string, unknown>,
912
918
  isRelay = false,
919
+ isKilo = false,
913
920
  ): Record<string, unknown> {
914
- // 1. Tool choice normalization (OpenCode Zen only supports "auto" or undefined)
921
+ // 1. Tool choice & empty tools normalization
922
+ if (Array.isArray(body.tools) && body.tools.length === 0) {
923
+ delete body.tools;
924
+ delete body.tool_choice;
925
+ }
915
926
  if (body.tool_choice === "none") {
916
927
  delete body.tool_choice;
917
928
  delete body.tools;
918
- } else if (body.tool_choice && body.tool_choice !== "auto") {
929
+ } else if (!isKilo && body.tool_choice && body.tool_choice !== "auto") {
930
+ // OpenCode Zen only supports "auto" or undefined
919
931
  body.tool_choice = "auto";
920
932
  }
921
933
 
922
934
  // 2. Reasoning normalization
935
+ const modelId = typeof body.model === "string" ? body.model : "";
936
+ const isResponsesApi = modelId === "muse-spark-1.2-contributor-free";
937
+
923
938
  if (typeof body.reasoning_effort === "string") {
924
939
  const re = body.reasoning_effort.toLowerCase();
925
940
  if (re === "xhigh" || re === "max") {
926
- body.reasoning_effort = "max";
941
+ body.reasoning_effort = isResponsesApi ? "xhigh" : (modelId === "x-preview-f-free" ? "max" : "xhigh");
927
942
  } else if (re === "high" || re === "medium") {
928
943
  body.reasoning_effort = "high";
929
944
  } else if (re === "minimal") {
@@ -940,18 +955,27 @@ function normalizeRequestBody(
940
955
  delete r.effort;
941
956
  } else if (typeof r.effort === "string") {
942
957
  const re = r.effort.toLowerCase();
943
- if (re === "xhigh" || re === "max") r.effort = "max";
944
- else if (re === "high" || re === "medium") r.effort = "high";
945
- else if (re === "minimal") r.effort = "minimal";
946
- else r.effort = "low";
958
+ if (re === "xhigh" || re === "max") {
959
+ r.effort = "xhigh"; // OpenCode Zen Responses API strictly requires "xhigh"
960
+ } else if (re === "high" || re === "medium") {
961
+ r.effort = "high";
962
+ } else if (re === "minimal") {
963
+ r.effort = "minimal";
964
+ } else {
965
+ r.effort = "low";
966
+ }
947
967
  }
948
968
  }
949
969
 
950
- // 3. Max & Min tokens clamping (OpenCode Zen requires min 16; Vercel relay caps at 64k)
970
+ // 3. Max & Min tokens clamping (model-specific clamp + Vercel relay clamp)
971
+ const modelDef = MODEL_MAP.get(modelId);
972
+ const modelMax = modelDef?.maxTokens ?? RELAY_MAX_TOKENS;
951
973
  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;
974
+ let clamped = val;
975
+ if (!isKilo && clamped < 16) clamped = 16;
976
+ if (clamped > modelMax) clamped = modelMax;
977
+ if (isRelay && clamped > RELAY_MAX_TOKENS) clamped = RELAY_MAX_TOKENS;
978
+ return clamped;
955
979
  };
956
980
 
957
981
  if (typeof body.max_tokens === "number") {
@@ -1027,7 +1051,7 @@ function pipeUpstreamStream(
1027
1051
  // ── Start local proxy ──────────────────────────────────────────────
1028
1052
  function startProxy(
1029
1053
  overridePort?: number,
1030
- ): Promise<{ server: http.Server; port: number }> {
1054
+ ): Promise<{ server: http.Server | null; port: number }> {
1031
1055
  const basePort = overridePort ?? PORT;
1032
1056
 
1033
1057
  const server = http.createServer((req, res) => {
@@ -1081,6 +1105,11 @@ function startProxy(
1081
1105
 
1082
1106
  // Read body to detect model for routing
1083
1107
  const bodyChunks: Buffer[] = [];
1108
+ req.on("error", (err) => {
1109
+ log("warn", "client request error during body buffering", { error: String(err) });
1110
+ if (!res.headersSent) res.writeHead(400, { "content-type": "application/json" });
1111
+ res.end(JSON.stringify({ error: "bad request" }));
1112
+ });
1084
1113
  req.on("data", (chunk: Buffer) => bodyChunks.push(chunk));
1085
1114
  req.on("end", async () => {
1086
1115
  const bodyStr = Buffer.concat(bodyChunks).toString();
@@ -1104,7 +1133,7 @@ function startProxy(
1104
1133
  // KiloCode gateway routing (free models are keyless)
1105
1134
  const isStream = parsedBody.stream === true;
1106
1135
  const kiloBodyObj = structuredClone(parsedBody);
1107
- normalizeRequestBody(kiloBodyObj, true);
1136
+ normalizeRequestBody(kiloBodyObj, true, isKilo);
1108
1137
  const response = await relayFetch(KILO_CHAT_URL, {
1109
1138
  method: "POST",
1110
1139
  headers: {
@@ -1142,7 +1171,7 @@ function startProxy(
1142
1171
  // round-robin: any saved relay qualifies, not just single url
1143
1172
  if (relayState.enabled && (relayState.url || relayState.relays.length > 0)) {
1144
1173
  const fullUrl = `${UPSTREAM_OPENCODE}${req.url ?? "/"}`;
1145
- const activeHost = new URL(relayState.url || DEFAULT_RELAY_URL).host;
1174
+ const activeHost = relayState.url ? new URL(relayState.url).host : "opencode.ai";
1146
1175
  const relayHeaders = sanitizeHeaders(
1147
1176
  req.headers,
1148
1177
  activeHost,
@@ -1153,7 +1182,7 @@ function startProxy(
1153
1182
  : undefined;
1154
1183
  if (relayBody && parsedBody) {
1155
1184
  const relayBodyObj = structuredClone(parsedBody);
1156
- normalizeRequestBody(relayBodyObj, true);
1185
+ normalizeRequestBody(relayBodyObj, true, isKilo);
1157
1186
  relayBody = Buffer.from(JSON.stringify(relayBodyObj));
1158
1187
  }
1159
1188
  const response = await relayFetch(fullUrl, {
@@ -1190,124 +1219,133 @@ function startProxy(
1190
1219
  return; // relay handled the response
1191
1220
  } catch (e) {
1192
1221
  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
-
1222
+ error: String(e),
1223
+ });
1224
+ if (res.headersSent) return; // can't recover mid-stream
1225
+ }
1226
+ }
1227
+ // direct path (existing, untouched)
1228
+ let directBody = Buffer.concat(bodyChunks);
1229
+ if (parsedBody) {
1230
+ const directBodyObj = structuredClone(parsedBody);
1231
+ normalizeRequestBody(directBodyObj, false, isKilo);
1232
+ directBody = Buffer.from(JSON.stringify(directBodyObj));
1233
+ }
1234
+ const fwd = sanitizeHeaders(req.headers, target.hostname);
1235
+ if (directBody.length > 0) {
1236
+ fwd["content-length"] = String(directBody.byteLength);
1237
+ }
1238
+ const proxy = https.request(
1239
+ {
1240
+ method: req.method,
1241
+ hostname: target.hostname,
1242
+ port: 443,
1243
+ path: target.pathname + target.search,
1244
+ headers: fwd,
1245
+ },
1246
+ (upstream) => {
1247
+ const outHeaders: Record<string, string> = {};
1248
+ for (const h of [
1249
+ "content-type",
1250
+ "cache-control",
1251
+ "x-request-id",
1252
+ ]) {
1253
+ const val = upstream.headers[h];
1254
+ if (typeof val === "string") outHeaders[h] = val;
1255
+ }
1256
+ outHeaders["x-content-type-options"] = "nosniff";
1257
+ res.writeHead(upstream.statusCode ?? 502, outHeaders);
1258
+ upstream.on("error", (streamErr) => {
1259
+ log("error", "upstream stream error in direct proxy", { error: String(streamErr) });
1260
+ if (!res.writableEnded) res.end();
1261
+ });
1262
+ upstream.pipe(res);
1263
+ },
1264
+ );
1265
+ proxy.on("error", (proxyErr) => {
1266
+ log("error", "proxy socket error", { error: String(proxyErr) });
1267
+ if (!res.headersSent) {
1268
+ res.writeHead(502, { "content-type": "application/json" });
1269
+ res.end(JSON.stringify({ error: "upstream error" }));
1270
+ } else if (!res.writableEnded) {
1271
+ res.end();
1272
+ }
1273
+ });
1274
+ proxy.setTimeout(300_000, () => {
1275
+ proxy.destroy(new Error("timeout"));
1276
+ });
1277
+ req.on("aborted", () => {
1278
+ if (!proxy.destroyed) proxy.destroy();
1279
+ });
1280
+ req.on("close", () => {
1281
+ if (!proxy.destroyed) proxy.destroy();
1282
+ });
1283
+ res.on("close", () => {
1284
+ if (!proxy.destroyed) proxy.destroy();
1285
+ });
1286
+ // ponytail: body already buffered in bodyChunks above for model routing;
1287
+ // req is drained so pipe() would send an empty body → upstream hang → 502.
1288
+ proxy.end(directBody);
1289
+ }
1290
+ } catch (err) {
1291
+ log("error", "proxy error", { error: String(err) });
1292
+ if (!res.headersSent)
1293
+ res.writeHead(502, { "content-type": "application/json" });
1294
+ res.end(JSON.stringify({ error: "internal error" }));
1295
+ }
1296
+ });
1297
+ });
1298
+
1299
+ return new Promise((resolve, reject) => {
1300
+ let attempt = 0;
1301
+ let settled = false;
1302
+ const tryListen = async (port: number) => {
1303
+ server.once("error", async (err: NodeJS.ErrnoException) => {
1304
+ if (settled) return;
1305
+ if (err.code === "EADDRINUSE") {
1306
+ // Re-check if the base port is alive (attached master race)
1307
+ if (await isProxyAlive(basePort)) {
1308
+ settled = true;
1309
+ log("info", `attached to running proxy on http://${HOST}:${basePort}`);
1310
+ resolve({ server: null, port: basePort });
1311
+ return;
1312
+ }
1313
+ if (attempt < 20) {
1314
+ attempt++;
1315
+ log("warn", `port ${port} taken — trying ${port + 1}`);
1316
+ tryListen(port + 1);
1317
+ return;
1318
+ }
1319
+ }
1320
+ settled = true;
1321
+ log("error", "server error", { code: err.code, message: err.message });
1322
+ reject(err);
1323
+ });
1324
+ server.listen(port, HOST, () => {
1325
+ if (settled) return;
1326
+ settled = true;
1327
+ const addr = server.address();
1328
+ const realPort = addr && typeof addr === "object" ? addr.port : port;
1329
+ log("info", `proxy listening on http://${HOST}:${realPort}`);
1330
+ resolve({ server, port: realPort });
1331
+ });
1332
+ };
1333
+ tryListen(basePort);
1334
+ });
1335
+ }
1336
+
1337
+ // Probe whether an existing freeflow proxy is already running on a port
1338
+ async function isProxyAlive(port: number): Promise<boolean> {
1339
+ try {
1340
+ const res = await fetch(`http://${HOST}:${port}/v1/models`, {
1341
+ signal: AbortSignal.timeout(500),
1342
+ });
1343
+ const ct = res.headers.get("content-type") || "";
1344
+ return res.ok && ct.includes("application/json");
1345
+ } catch {
1346
+ return false;
1347
+ }
1348
+ }
1311
1349
  // ── Main extension ─────────────────────────────────────────────────
1312
1350
  export default async function (pi: ExtensionAPI) {
1313
1351
  log("info", "extension loading...");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.1.0",
4
+ "version": "1.1.2",
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",