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.
- package/extensions/index.ts +172 -139
- package/package.json +1 -1
package/extensions/index.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
953
|
-
if (
|
|
954
|
-
|
|
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
|
|
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
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
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
|
}
|