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.
- package/extensions/index.ts +178 -140
- 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 {
|
|
@@ -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
|
-
|
|
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
|
|
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")
|
|
944
|
-
|
|
945
|
-
else if (re === "
|
|
946
|
-
|
|
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 (
|
|
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
|
-
|
|
953
|
-
if (
|
|
954
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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...");
|