pi-freeflow 1.8.0 → 1.8.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/README.md +7 -1
- package/package.json +1 -1
- package/src/catalog.ts +22 -1
- package/src/commands.ts +29 -18
- package/src/config.ts +14 -1
- package/src/deploy.ts +3 -1
- package/src/health.ts +13 -7
- package/src/index.ts +141 -27
- package/src/logger.ts +4 -0
- package/src/proxy.ts +63 -2
- package/src/relay-state.ts +1 -1
package/README.md
CHANGED
|
@@ -269,7 +269,7 @@ Current size: about 11.3k lines including tests. 230 tests pass, typecheck clean
|
|
|
269
269
|
### FAQ
|
|
270
270
|
|
|
271
271
|
**Do I need API keys?**
|
|
272
|
-
No. Kilo uses `Bearer kilo-free`, OpenCode
|
|
272
|
+
No. Kilo uses `Bearer kilo-free`, OpenCode free models are anonymous (no header needed).
|
|
273
273
|
|
|
274
274
|
**What if all relays are 429?**
|
|
275
275
|
Proxy tries direct. If that is also 429, Pi shows the rate limit. That number is the global upstream cap; without relays you would hit the same wall.
|
|
@@ -277,6 +277,12 @@ Proxy tries direct. If that is also 429, Pi shows the rate limit. That number is
|
|
|
277
277
|
**Can I use without relays?**
|
|
278
278
|
Yes. `/freeflow off` → direct. Add relays later to scale.
|
|
279
279
|
|
|
280
|
+
**What happens when I update to a new version?**
|
|
281
|
+
The local proxy daemon is shared across sessions on port 28180. On upgrade, the new extension
|
|
282
|
+
detects a stale daemon (mismatched internal version) and replaces it automatically — no manual
|
|
283
|
+
kill, no restart of other sessions required. If an old daemon cannot be replaced (e.g. port held
|
|
284
|
+
by an unrelated process), it falls back to reusing it with a warning.
|
|
285
|
+
|
|
280
286
|
**Where's the normalizer?**
|
|
281
287
|
Deleted in 1.3.0. If zai/qwen/deepseek thinking broke before, it's fixed now because host handles it.
|
|
282
288
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.8.
|
|
4
|
+
"version": "1.8.2",
|
|
5
5
|
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
6
|
"main": "extensions/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
package/src/catalog.ts
CHANGED
|
@@ -191,7 +191,28 @@ export function readCatalogCache(): CatalogCacheData | null {
|
|
|
191
191
|
/**
|
|
192
192
|
* Atomically write catalog cache data to disk using temporary file + rename.
|
|
193
193
|
* Persists etag alongside models for subsequent If-None-Match conditional requests.
|
|
194
|
+
* Note: fsync not needed — rename is atomic on same filesystem; crash leaves either old or new file intact.
|
|
194
195
|
*/
|
|
196
|
+
function sleepSync(ms: number): void {
|
|
197
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function renameWithRetry(from: string, to: string): void {
|
|
201
|
+
const retryable: Record<string, true> = { EPERM: true, EACCES: true, EBUSY: true };
|
|
202
|
+
for (let attempt = 1; ; attempt++) {
|
|
203
|
+
try {
|
|
204
|
+
fs.renameSync(from, to);
|
|
205
|
+
return;
|
|
206
|
+
} catch (e) {
|
|
207
|
+
const code = (e as NodeJS.ErrnoException | null)?.code;
|
|
208
|
+
if (!code || !retryable[code] || attempt >= 3) {
|
|
209
|
+
throw e;
|
|
210
|
+
}
|
|
211
|
+
sleepSync(50);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
195
216
|
export function writeCatalogCache(data: CatalogCacheData): void {
|
|
196
217
|
try {
|
|
197
218
|
const dir = path.dirname(CATALOG_CACHE_FILE);
|
|
@@ -200,7 +221,7 @@ export function writeCatalogCache(data: CatalogCacheData): void {
|
|
|
200
221
|
}
|
|
201
222
|
const tmpPath = `${CATALOG_CACHE_FILE}.${randomUUID()}.tmp`;
|
|
202
223
|
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf8");
|
|
203
|
-
|
|
224
|
+
renameWithRetry(tmpPath, CATALOG_CACHE_FILE);
|
|
204
225
|
} catch (err) {
|
|
205
226
|
logWarn("Could not persist catalog cache to disk", { error: String(err) });
|
|
206
227
|
}
|
package/src/commands.ts
CHANGED
|
@@ -350,12 +350,13 @@ export function createCommandSpec(
|
|
|
350
350
|
const { url, auth } = await deployer(token, name, (m) =>
|
|
351
351
|
ctx.ui.notify(m, "info"),
|
|
352
352
|
);
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
353
|
+
relayState = withRelayState((s) => {
|
|
354
|
+
const r = ensureRelay(s, url, `deployed ${name}`);
|
|
355
|
+
r.auth = auth;
|
|
356
|
+
s.enabled = true;
|
|
357
|
+
s.url = url;
|
|
358
|
+
return s;
|
|
359
|
+
});
|
|
359
360
|
persist();
|
|
360
361
|
let probeNote = "";
|
|
361
362
|
try {
|
|
@@ -589,12 +590,17 @@ export function createCommandSpec(
|
|
|
589
590
|
const tokens = rest.trim().split(/\s+/);
|
|
590
591
|
const targetUrl = tokens[0];
|
|
591
592
|
const customLabel = tokens.slice(1).join(" ").trim() || undefined;
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
593
|
+
try {
|
|
594
|
+
applyRelayState((s) => {
|
|
595
|
+
const added = ensureRelay(s, targetUrl, customLabel);
|
|
596
|
+
s.enabled = true;
|
|
597
|
+
s.url = added.url;
|
|
598
|
+
return s;
|
|
599
|
+
});
|
|
600
|
+
} catch (e) {
|
|
601
|
+
ctx.ui.notify((e as Error).message, "warning");
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
598
604
|
persist();
|
|
599
605
|
flash();
|
|
600
606
|
ctx.ui.notify(
|
|
@@ -632,12 +638,17 @@ export function createCommandSpec(
|
|
|
632
638
|
} catch {}
|
|
633
639
|
const looksLikeUrl = parsedUrl && (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:");
|
|
634
640
|
if (looksLikeUrl) {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
+
try {
|
|
642
|
+
applyRelayState((s) => {
|
|
643
|
+
const added = ensureRelay(s, targetToken, customLabel);
|
|
644
|
+
s.enabled = true;
|
|
645
|
+
s.url = added.url;
|
|
646
|
+
return s;
|
|
647
|
+
});
|
|
648
|
+
} catch (e) {
|
|
649
|
+
ctx.ui.notify((e as Error).message, "warning");
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
641
652
|
persist();
|
|
642
653
|
flash();
|
|
643
654
|
} else {
|
package/src/config.ts
CHANGED
|
@@ -6,8 +6,21 @@ import { randomUUID } from "node:crypto";
|
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
9
10
|
import type { Upstream } from "./types.ts";
|
|
10
11
|
|
|
12
|
+
// Package version — stale-daemon detection in the shared-port reuse path.
|
|
13
|
+
let PKG_VERSION = "0.0.0";
|
|
14
|
+
try {
|
|
15
|
+
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "../package.json");
|
|
16
|
+
const raw: unknown = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
17
|
+
if (raw && typeof raw === "object" && "version" in raw) {
|
|
18
|
+
const v = raw.version;
|
|
19
|
+
if (typeof v === "string" && v) PKG_VERSION = v;
|
|
20
|
+
}
|
|
21
|
+
} catch {}
|
|
22
|
+
export { PKG_VERSION };
|
|
23
|
+
|
|
11
24
|
// ── Upstream endpoints ──────────────────────────────────────────────
|
|
12
25
|
export const UPSTREAM_OPENCODE = "https://opencode.ai/zen";
|
|
13
26
|
export const KILO_CHAT_URL = "https://api.kilo.ai/api/gateway/chat/completions";
|
|
@@ -62,7 +75,7 @@ export const RATE_LIMIT_MAX: Record<Upstream, number> = {
|
|
|
62
75
|
};
|
|
63
76
|
|
|
64
77
|
// ── Whitelists & Security ───────────────────────────────────────────
|
|
65
|
-
export const ALLOWED_PATH_PATTERN = /^\/v1\/[a-zA-Z0-9/_.,\-?&=]*$/;
|
|
78
|
+
export const ALLOWED_PATH_PATTERN = /^\/v1\/[a-zA-Z0-9/_.,\-?&= %]*$/;
|
|
66
79
|
export const PATH_TRAVERSAL_PATTERN = /\.\./;
|
|
67
80
|
export const ALLOWED_METHODS = new Set(["GET", "POST", "OPTIONS", "HEAD"]);
|
|
68
81
|
|
package/src/deploy.ts
CHANGED
|
@@ -75,6 +75,8 @@ const timingSafeEqualStr = function(a, b) {
|
|
|
75
75
|
// (never forwarded upstream) instead of living in the denylist.
|
|
76
76
|
const DENY_HEADERS = ["host", "connection", "content-length", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", "transfer-encoding", "te", "trailer", "upgrade", "x-relay-target", "x-relay-path"];
|
|
77
77
|
async function relayHandler(req) {
|
|
78
|
+
if (!["GET", "POST", "HEAD", "OPTIONS"].includes(req.method)) return new Response("Method Not Allowed", { status: 405 });
|
|
79
|
+
if (Number(req.headers.get("content-length") || 0) > 32 * 1024 * 1024) return new Response("Payload Too Large", { status: 413 });
|
|
78
80
|
const target = req.headers.get("x-relay-target");
|
|
79
81
|
if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
80
82
|
if (RELAY_AUTH && !timingSafeEqualStr(req.headers.get("x-relay-auth") || "", RELAY_AUTH)) {
|
|
@@ -159,11 +161,11 @@ export async function deployVercelRelay(
|
|
|
159
161
|
name: string,
|
|
160
162
|
onProgress?: (msg: string) => void,
|
|
161
163
|
): Promise<{ url: string; auth: string }> {
|
|
164
|
+
name = baseRelayName(name) || "relay-worker";
|
|
162
165
|
const auth = {
|
|
163
166
|
Authorization: `Bearer ${token}`,
|
|
164
167
|
"Content-Type": "application/json",
|
|
165
168
|
};
|
|
166
|
-
|
|
167
169
|
// 1. Create deployment (3 inline files, no git repository required)
|
|
168
170
|
onProgress?.("Uploading relay files to Vercel…");
|
|
169
171
|
log("info", `Starting Vercel deployment: ${name}`);
|
package/src/health.ts
CHANGED
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
import type * as http from "node:http";
|
|
7
7
|
import { ALL_MODELS } from "./models.ts";
|
|
8
8
|
import { getActiveRelayState, getRelayHealth, isRelayHealthy } from "./relay-state.ts";
|
|
9
|
-
import { PORT } from "./config.ts";
|
|
10
|
-
import { getClientIP } from "./proxy.ts";
|
|
9
|
+
import { PKG_VERSION, PORT } from "./config.ts";
|
|
11
10
|
|
|
12
11
|
export interface HealthRelayInfo {
|
|
13
12
|
url: string;
|
|
@@ -24,8 +23,8 @@ export interface HealthData {
|
|
|
24
23
|
enabled: boolean;
|
|
25
24
|
relays: HealthRelayInfo[];
|
|
26
25
|
catalog: number;
|
|
26
|
+
version: string;
|
|
27
27
|
}
|
|
28
|
-
|
|
29
28
|
/**
|
|
30
29
|
* Collect current health snapshot.
|
|
31
30
|
* @param portOverride - actual listening port (defaults to config PORT)
|
|
@@ -50,12 +49,14 @@ export function getHealthData(portOverride?: number): HealthData {
|
|
|
50
49
|
enabled: Boolean(state.enabled),
|
|
51
50
|
relays,
|
|
52
51
|
catalog: ALL_MODELS.length,
|
|
52
|
+
version: PKG_VERSION,
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
function isLoopbackIP(ip: string): boolean {
|
|
57
57
|
if (!ip) return false;
|
|
58
|
-
const
|
|
58
|
+
const withoutZone = ip.split("%")[0];
|
|
59
|
+
const clean = withoutZone.startsWith("::ffff:") ? withoutZone.slice(7) : withoutZone;
|
|
59
60
|
return clean === "127.0.0.1" || clean === "::1" || clean === "localhost";
|
|
60
61
|
}
|
|
61
62
|
|
|
@@ -76,13 +77,18 @@ export function handleHealthRequest(
|
|
|
76
77
|
return false;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
const isHealthPath =
|
|
80
|
-
pathname === "/_health" || pathname === "/health" || pathname.endsWith("/health");
|
|
80
|
+
const isHealthPath = pathname === "/_health" || pathname === "/health";
|
|
81
81
|
if (req.method !== "GET" || !isHealthPath) {
|
|
82
82
|
return false;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
const
|
|
85
|
+
const sock: unknown = req.socket;
|
|
86
|
+
let rawIp = "";
|
|
87
|
+
if (sock && typeof sock === "object" && "remoteAddress" in sock) {
|
|
88
|
+
const v = sock.remoteAddress;
|
|
89
|
+
if (typeof v === "string") rawIp = v;
|
|
90
|
+
}
|
|
91
|
+
const clientIP = rawIp.startsWith("::ffff:") ? rawIp.slice(7) : rawIp;
|
|
86
92
|
if (!isLoopbackIP(clientIP)) {
|
|
87
93
|
const body = JSON.stringify({ error: "forbidden" });
|
|
88
94
|
res.writeHead(403, { "content-type": "application/json", "content-length": Buffer.byteLength(body) });
|
package/src/index.ts
CHANGED
|
@@ -18,10 +18,10 @@ import {
|
|
|
18
18
|
setAliveCatalog,
|
|
19
19
|
} from "./catalog.ts";
|
|
20
20
|
import { createCommandSpec, updateStatusBar } from "./commands.ts";
|
|
21
|
-
import { HOST, LEGACY_PORT, ONBOARDED_FLAG_FILE, PORT } from "./config.ts";
|
|
21
|
+
import { HOST, LEGACY_PORT, ONBOARDED_FLAG_FILE, PKG_VERSION, PORT } from "./config.ts";
|
|
22
22
|
import { log, logInfo, logWarn } from "./logger.ts";
|
|
23
23
|
import { ALL_MODELS, KILO_MODEL_IDS, MODEL_MAP, resolveCanonicalModelId } from "./models.ts";
|
|
24
|
-
import { isProxyAlive, startProxy } from "./proxy.ts";
|
|
24
|
+
import { getDaemonVersion, isProxyAlive, killPortHolder, startProxy } from "./proxy.ts";
|
|
25
25
|
import { resetRateLimits } from "./rate-limiter.ts";
|
|
26
26
|
import { checkForUpdateInBackground } from "./update-checker.ts";
|
|
27
27
|
import {
|
|
@@ -150,7 +150,7 @@ export function buildProviderConfig(
|
|
|
150
150
|
: m.source === "kilo"
|
|
151
151
|
? {
|
|
152
152
|
supportsDeveloperRole: false,
|
|
153
|
-
supportsReasoningEffort:
|
|
153
|
+
supportsReasoningEffort: !!m.thinkingLevelMap,
|
|
154
154
|
}
|
|
155
155
|
: {
|
|
156
156
|
supportsDeveloperRole: false,
|
|
@@ -174,18 +174,81 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
174
174
|
// 2. Single-Port Shared Pattern: Check if daemon is already running (e.g. parent session)
|
|
175
175
|
// Dual-probe: probe current PORT first; if missing, check LEGACY_PORT so existing v1.4.9
|
|
176
176
|
// sessions on 18080 are seamlessly reused without split-brain or duplicate daemons.
|
|
177
|
+
// Stale-daemon heal: if the alive daemon reports a different version (e.g. 1.7.1 vs 1.8.0
|
|
178
|
+
// after an upgrade), the auth fix never loads for the new session. Detect via /_health
|
|
179
|
+
// and best-effort replace the stale holder so old users are auto-healed.
|
|
177
180
|
let alreadyRunning = await isProxyAlive(PORT);
|
|
178
181
|
if (alreadyRunning) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
182
|
+
const remoteVer = await getDaemonVersion(PORT);
|
|
183
|
+
if (remoteVer !== null && remoteVer !== PKG_VERSION) {
|
|
184
|
+
logWarn(`stale proxy daemon v${remoteVer} on :${PORT} (need v${PKG_VERSION}) — replacing`, {
|
|
185
|
+
remoteVer,
|
|
186
|
+
expected: PKG_VERSION,
|
|
187
|
+
});
|
|
188
|
+
const killed = await killPortHolder(PORT);
|
|
189
|
+
if (killed) {
|
|
190
|
+
for (let i = 0; i < 10; i++) {
|
|
191
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
192
|
+
if (!(await isProxyAlive(PORT))) break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (!(await isProxyAlive(PORT))) {
|
|
196
|
+
try {
|
|
197
|
+
const r = await startProxy();
|
|
198
|
+
server = r.server;
|
|
199
|
+
actualPort = r.port;
|
|
200
|
+
alreadyRunning = false;
|
|
201
|
+
} catch (e) {
|
|
202
|
+
log("error", "stale daemon replaced but fresh bind failed — reusing stale as fallback", {
|
|
203
|
+
error: String(e),
|
|
204
|
+
});
|
|
205
|
+
actualPort = PORT;
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
logInfo(`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${PORT} (stale kill did not free port)`);
|
|
209
|
+
actualPort = PORT;
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
logInfo(`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${PORT}`);
|
|
213
|
+
actualPort = PORT;
|
|
214
|
+
}
|
|
183
215
|
} else if (PORT !== LEGACY_PORT && (await isProxyAlive(LEGACY_PORT))) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
216
|
+
const remoteVer = await getDaemonVersion(LEGACY_PORT);
|
|
217
|
+
if (remoteVer !== null && remoteVer !== PKG_VERSION) {
|
|
218
|
+
logWarn(`stale legacy daemon v${remoteVer} on :${LEGACY_PORT} (need v${PKG_VERSION}) — replacing`, {
|
|
219
|
+
remoteVer,
|
|
220
|
+
expected: PKG_VERSION,
|
|
221
|
+
});
|
|
222
|
+
const killed = await killPortHolder(LEGACY_PORT);
|
|
223
|
+
if (killed) {
|
|
224
|
+
for (let i = 0; i < 10; i++) {
|
|
225
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
226
|
+
if (!(await isProxyAlive(LEGACY_PORT))) break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (!(await isProxyAlive(LEGACY_PORT)) && !(await isProxyAlive(PORT))) {
|
|
230
|
+
try {
|
|
231
|
+
const r = await startProxy();
|
|
232
|
+
server = r.server;
|
|
233
|
+
actualPort = r.port;
|
|
234
|
+
alreadyRunning = false;
|
|
235
|
+
} catch (e) {
|
|
236
|
+
log("error", "stale legacy daemon replaced but fresh bind failed — reusing as fallback", {
|
|
237
|
+
error: String(e),
|
|
238
|
+
});
|
|
239
|
+
alreadyRunning = true;
|
|
240
|
+
actualPort = LEGACY_PORT;
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
logInfo(`Reusing existing legacy pi-freeflow proxy daemon on http://${HOST}:${LEGACY_PORT}`);
|
|
244
|
+
alreadyRunning = true;
|
|
245
|
+
actualPort = LEGACY_PORT;
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
logInfo(`Reusing existing legacy pi-freeflow proxy daemon on http://${HOST}:${LEGACY_PORT}`);
|
|
249
|
+
alreadyRunning = true;
|
|
250
|
+
actualPort = LEGACY_PORT;
|
|
251
|
+
}
|
|
189
252
|
} else {
|
|
190
253
|
try {
|
|
191
254
|
const r = await startProxy();
|
|
@@ -200,8 +263,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
200
263
|
return;
|
|
201
264
|
}
|
|
202
265
|
}
|
|
203
|
-
|
|
204
|
-
// 3. Instant 0ms Static Catalog Registration
|
|
205
266
|
// Register static models immediately on boot so Pi/OMP picker is populated with zero latency!
|
|
206
267
|
const registeredCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
|
|
207
268
|
...m,
|
|
@@ -215,27 +276,80 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
215
276
|
buildProviderConfig(models, actualPort),
|
|
216
277
|
);
|
|
217
278
|
};
|
|
218
|
-
registerCatalog(registeredCatalog);
|
|
219
|
-
// Self-heal: if this session attached to an external daemon that later died,
|
|
220
|
-
// re-bind on the next lifecycle event so model calls recover without a restart.
|
|
221
279
|
let ensuringDaemon = false;
|
|
222
280
|
const ensureDaemon = async (): Promise<void> => {
|
|
223
281
|
if (ensuringDaemon) return;
|
|
224
282
|
ensuringDaemon = true;
|
|
225
283
|
try {
|
|
226
|
-
if (await isProxyAlive(actualPort))
|
|
227
|
-
|
|
284
|
+
if (await isProxyAlive(actualPort)) {
|
|
285
|
+
const v = await getDaemonVersion(actualPort);
|
|
286
|
+
if (v === null || v === PKG_VERSION) return;
|
|
287
|
+
logWarn(`proxy on :${actualPort} is stale v${v} (need v${PKG_VERSION}) — replacing`, {
|
|
288
|
+
remoteVer: v,
|
|
289
|
+
expected: PKG_VERSION,
|
|
290
|
+
});
|
|
291
|
+
const killed = await killPortHolder(actualPort);
|
|
292
|
+
if (killed) {
|
|
293
|
+
for (let i = 0; i < 10; i++) {
|
|
294
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
295
|
+
if (!(await isProxyAlive(actualPort))) break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (await isProxyAlive(actualPort)) return;
|
|
299
|
+
}
|
|
228
300
|
if (await isProxyAlive(PORT)) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
301
|
+
const v = await getDaemonVersion(PORT);
|
|
302
|
+
if (v !== null && v !== PKG_VERSION) {
|
|
303
|
+
logWarn(`proxy on :${PORT} is stale v${v} (need v${PKG_VERSION}) — replacing`, {
|
|
304
|
+
remoteVer: v,
|
|
305
|
+
expected: PKG_VERSION,
|
|
306
|
+
});
|
|
307
|
+
const killed = await killPortHolder(PORT);
|
|
308
|
+
if (killed) {
|
|
309
|
+
for (let i = 0; i < 10; i++) {
|
|
310
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
311
|
+
if (!(await isProxyAlive(PORT))) break;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (await isProxyAlive(PORT)) {
|
|
315
|
+
actualPort = PORT;
|
|
316
|
+
registerCatalog(getAliveCatalog());
|
|
317
|
+
logInfo(`Re-attached to proxy daemon on http://${HOST}:${PORT} (stale kill did not free port)`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
} else {
|
|
321
|
+
actualPort = PORT;
|
|
322
|
+
registerCatalog(getAliveCatalog());
|
|
323
|
+
logInfo(`Re-attached to proxy daemon on http://${HOST}:${PORT}`);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
233
326
|
}
|
|
234
327
|
if (PORT !== LEGACY_PORT && (await isProxyAlive(LEGACY_PORT))) {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
328
|
+
const v = await getDaemonVersion(LEGACY_PORT);
|
|
329
|
+
if (v !== null && v !== PKG_VERSION) {
|
|
330
|
+
logWarn(`legacy proxy on :${LEGACY_PORT} is stale v${v} (need v${PKG_VERSION}) — replacing`, {
|
|
331
|
+
remoteVer: v,
|
|
332
|
+
expected: PKG_VERSION,
|
|
333
|
+
});
|
|
334
|
+
const killed = await killPortHolder(LEGACY_PORT);
|
|
335
|
+
if (killed) {
|
|
336
|
+
for (let i = 0; i < 10; i++) {
|
|
337
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
338
|
+
if (!(await isProxyAlive(LEGACY_PORT))) break;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (await isProxyAlive(LEGACY_PORT)) {
|
|
342
|
+
actualPort = LEGACY_PORT;
|
|
343
|
+
registerCatalog(getAliveCatalog());
|
|
344
|
+
logInfo(`Re-attached to legacy proxy daemon on http://${HOST}:${LEGACY_PORT} (stale kill did not free port)`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
} else {
|
|
348
|
+
actualPort = LEGACY_PORT;
|
|
349
|
+
registerCatalog(getAliveCatalog());
|
|
350
|
+
logInfo(`Re-attached to legacy proxy daemon on http://${HOST}:${LEGACY_PORT}`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
239
353
|
}
|
|
240
354
|
const r = await startProxy();
|
|
241
355
|
if (r.server) server = r.server;
|
package/src/logger.ts
CHANGED
|
@@ -197,6 +197,10 @@ export function formatLogMeta(
|
|
|
197
197
|
if (meta && Object.keys(meta).length > 0) {
|
|
198
198
|
const safe: Record<string, unknown> = {};
|
|
199
199
|
for (const [k, v] of Object.entries(meta)) {
|
|
200
|
+
if (/(auth|token|secret|password|bearer|api.?key|cookie|x-relay-auth)/i.test(k)) {
|
|
201
|
+
safe[k] = "[REDACTED]";
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
200
204
|
if (typeof v === "string" && v.length > 800) {
|
|
201
205
|
safe[k] = `${v.slice(0, 800)}…(${v.length})`;
|
|
202
206
|
} else if (v instanceof Error) {
|
package/src/proxy.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { execSync } from "node:child_process";
|
|
9
10
|
import * as http from "node:http";
|
|
10
11
|
import * as https from "node:https";
|
|
11
12
|
import { handleHealthRequest } from "./health.ts";
|
|
@@ -65,6 +66,7 @@ export function validatePath(rawUrl: string): URL | null {
|
|
|
65
66
|
if (PATH_TRAVERSAL_PATTERN.test(cleaned)) return null;
|
|
66
67
|
try {
|
|
67
68
|
const decoded = decodeURIComponent(cleaned);
|
|
69
|
+
if (PATH_TRAVERSAL_PATTERN.test(decoded)) return null;
|
|
68
70
|
if (decoded !== cleaned && !ALLOWED_PATH_PATTERN.test(`/${decoded}`)) {
|
|
69
71
|
return null;
|
|
70
72
|
}
|
|
@@ -112,6 +114,7 @@ export function sanitizeHeaders(
|
|
|
112
114
|
return sanitized;
|
|
113
115
|
}
|
|
114
116
|
export async function isProxyAlive(port: number): Promise<boolean> {
|
|
117
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
|
|
115
118
|
try {
|
|
116
119
|
const res = await fetch(`http://${HOST}:${port}/v1/models`, {
|
|
117
120
|
signal: AbortSignal.timeout(500),
|
|
@@ -123,6 +126,63 @@ export async function isProxyAlive(port: number): Promise<boolean> {
|
|
|
123
126
|
}
|
|
124
127
|
}
|
|
125
128
|
|
|
129
|
+
export async function getDaemonVersion(port: number): Promise<string | null> {
|
|
130
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
131
|
+
try {
|
|
132
|
+
const res = await fetch(`http://${HOST}:${port}/_health`, {
|
|
133
|
+
signal: AbortSignal.timeout(800),
|
|
134
|
+
});
|
|
135
|
+
if (!res.ok) return null;
|
|
136
|
+
const data: unknown = await res.json();
|
|
137
|
+
if (data && typeof data === "object" && "version" in data) {
|
|
138
|
+
const v = data.version;
|
|
139
|
+
if (typeof v === "string" && v) return v;
|
|
140
|
+
}
|
|
141
|
+
// Alive but no version field = pre-1.8.0 daemon (never embedded it) — stale.
|
|
142
|
+
return "";
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function killPortHolder(port: number): Promise<boolean> {
|
|
149
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
|
|
150
|
+
try {
|
|
151
|
+
if (process.platform === "win32") {
|
|
152
|
+
try {
|
|
153
|
+
const out = execSync(`netstat -ano | findstr :${port}`, { encoding: "utf8", timeout: 3000 }) as string;
|
|
154
|
+
for (const line of out.split("\n")) {
|
|
155
|
+
if (!line.includes("LISTENING")) continue;
|
|
156
|
+
if (!line.includes(`:${port}`)) continue;
|
|
157
|
+
const parts = line.trim().split(/\s+/);
|
|
158
|
+
const pid = parts[parts.length - 1];
|
|
159
|
+
if (!pid || !/^\d+$/.test(pid)) continue;
|
|
160
|
+
// Windows netstat report: 127.0.0.1:38180 ... LISTENING/PID
|
|
161
|
+
if (Number(pid) === process.pid) continue; // never self-kill
|
|
162
|
+
execSync(`taskkill /F /PID ${pid}`, { timeout: 3000, stdio: "ignore" });
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
} catch {}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const out = execSync(`lsof -ti tcp:${port} 2>/dev/null || fuser -n tcp ${port} 2>/dev/null`, {
|
|
170
|
+
encoding: "utf8",
|
|
171
|
+
timeout: 3000,
|
|
172
|
+
}) as string;
|
|
173
|
+
const pid = out.trim().split(/\s+/)[0];
|
|
174
|
+
if (!pid || !/^\d+$/.test(pid)) return false;
|
|
175
|
+
if (Number(pid) === process.pid) return false; // never self-kill (Unix) — matches Windows guard at 161
|
|
176
|
+
execSync(`kill -9 ${pid}`, { timeout: 3000, stdio: "ignore" });
|
|
177
|
+
return true;
|
|
178
|
+
} catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
126
186
|
/**
|
|
127
187
|
* Tagged abort reason for the proxy-internal header-wait timeout.
|
|
128
188
|
* relayFetch rethrows AbortErrors untouched, and stream-pipe recognizes
|
|
@@ -179,7 +239,7 @@ export function startProxy(
|
|
|
179
239
|
reqPathname = new URL(req.url ?? "/", `http://${HOST}`).pathname;
|
|
180
240
|
} catch {}
|
|
181
241
|
// Loopback-only health endpoint — always accessible even when widget hidden
|
|
182
|
-
if (req.method === "GET" && reqPathname !== null && (reqPathname === "/_health" || reqPathname === "/health"
|
|
242
|
+
if (req.method === "GET" && reqPathname !== null && (reqPathname === "/_health" || reqPathname === "/health")) {
|
|
183
243
|
const addr = server.address();
|
|
184
244
|
const realPort = addr && typeof addr === "object" ? (addr as { port: number }).port : basePort;
|
|
185
245
|
if (handleHealthRequest(req, res, realPort)) return;
|
|
@@ -464,7 +524,7 @@ export function startProxy(
|
|
|
464
524
|
fwd["connection"] = "keep-alive";
|
|
465
525
|
|
|
466
526
|
const controller = new AbortController();
|
|
467
|
-
const timeoutId = setTimeout(() => controller.abort(),
|
|
527
|
+
const timeoutId = setTimeout(() => controller.abort(upstreamTimeoutError()), UPSTREAM_HEADER_TIMEOUT_MS);
|
|
468
528
|
const onClientClose = () => {
|
|
469
529
|
if (!res.writableEnded) controller.abort();
|
|
470
530
|
};
|
|
@@ -542,6 +602,7 @@ export function startProxy(
|
|
|
542
602
|
let settled = false;
|
|
543
603
|
|
|
544
604
|
const tryListen = async (port: number) => {
|
|
605
|
+
server.removeAllListeners("error");
|
|
545
606
|
server.once("error", async (err: NodeJS.ErrnoException) => {
|
|
546
607
|
if (settled) return;
|
|
547
608
|
if (err.code === "EADDRINUSE") {
|
package/src/relay-state.ts
CHANGED
|
@@ -512,7 +512,7 @@ lastKnownStateMtimeMs = currentDiskStateMtimeMs();
|
|
|
512
512
|
*/
|
|
513
513
|
export function getActiveRelayState(): RelayState {
|
|
514
514
|
const currentMtime = currentDiskStateMtimeMs();
|
|
515
|
-
if (currentMtime
|
|
515
|
+
if (currentMtime !== lastKnownStateMtimeMs) {
|
|
516
516
|
activeRelayState = resolveRelayState();
|
|
517
517
|
lastKnownStateMtimeMs = currentMtime;
|
|
518
518
|
}
|