pi-freeflow 1.8.1 → 1.9.0
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/CHANGELOG.md +62 -0
- package/README.md +7 -4
- package/package.json +3 -2
- package/src/catalog.ts +22 -1
- package/src/commands.ts +29 -18
- package/src/config.ts +23 -2
- package/src/deploy.ts +3 -1
- package/src/health.ts +9 -3
- package/src/index.ts +38 -8
- package/src/logger.ts +4 -0
- package/src/proxy.ts +39 -8
- package/src/relay-state.ts +1 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to pi-freeflow. Public, user-visible behavior only.
|
|
4
|
+
|
|
5
|
+
## 1.9.0 - 2026-08-30
|
|
6
|
+
|
|
7
|
+
### Development hardening
|
|
8
|
+
- Test suite now runs fully sandboxed: every test uses a temporary data directory, so no test can ever write to your real `~/.pi/agent/` files or interfere with a running local proxy. A single env override (`PI_*_DATA_DIR`) re-roots all data files for tests/CI.
|
|
9
|
+
- Added a complete mocked user-flow e2e suite: fresh install → onboarding → proxy health → direct chat → relay add/roll/fallback → guided deploy → update check → command surface — all deterministic, zero network.
|
|
10
|
+
- Docs: test counts no longer hardcoded in README (they drifted with releases); release history tracked here.
|
|
11
|
+
|
|
12
|
+
### Fixes
|
|
13
|
+
- **Stale-daemon replacement no longer interrupts running sessions.** The shared local proxy can be held by another OMP/Pi session; the old upgrade logic killed that holder on version mismatch, which could terminate the session that owned the proxy mid-stream. Replacement now only happens when the running daemon is strictly older AND idle (0 in-flight requests, reported via `/_health`); newer or busy daemons are reused with a log note instead. Optional opt-out: set the no-kill env to `1` (see README FAQ for upgrades).
|
|
14
|
+
|
|
15
|
+
## 1.8.2 - 2026-08-30
|
|
16
|
+
|
|
17
|
+
### Fixes & polish
|
|
18
|
+
- Deploy: compare-and-swap on concurrent deployments (no duplicate relays when two sessions deploy at once).
|
|
19
|
+
- Proxy: URL-encoding edge cases (`%` in paths), startup timeout and kill handling, port conflict fallback.
|
|
20
|
+
- Logs: sanitized sensitive headers in debug output.
|
|
21
|
+
- Kilo gateway models: compatibility pass for all 25 models.
|
|
22
|
+
- Docs site build included; README per-host usage guide (Oh My Pi & Pi install + pick + manage).
|
|
23
|
+
|
|
24
|
+
## 1.8.1 - 2026-08-29
|
|
25
|
+
|
|
26
|
+
- Fix: stale-daemon auto-heal is now shipped in the published package (previously only in the repo).
|
|
27
|
+
|
|
28
|
+
## 1.8.0 - 2026-08-29
|
|
29
|
+
|
|
30
|
+
### Automatic upgrades
|
|
31
|
+
- On version upgrade, the extension detects a stale local proxy daemon and replaces it automatically — users get fixes without manual restarts or killing sessions.
|
|
32
|
+
- Fix: client auth key stripped before reaching the opencode.ai/zen upstream (failed with 401 for some clients).
|
|
33
|
+
- 3 new free models (28 total).
|
|
34
|
+
|
|
35
|
+
## 1.7.1 - 2026-08-29
|
|
36
|
+
|
|
37
|
+
- Docs: clarified npm is the distribution channel (the package is an extension loaded by OMP/Pi, not a standalone CLI).
|
|
38
|
+
|
|
39
|
+
## 1.7.0 - 2026-08-29
|
|
40
|
+
|
|
41
|
+
- 4 new free models (25 total) with live-verified specs (context/output limits, vision, thinking levels).
|
|
42
|
+
- New alias map aligned with host model selectors.
|
|
43
|
+
|
|
44
|
+
## 1.6.1 - 2026-08-29
|
|
45
|
+
|
|
46
|
+
- Catalog: thinking-level map locked per model; docs sync.
|
|
47
|
+
|
|
48
|
+
## 1.6.0 - 2026-08-29
|
|
49
|
+
|
|
50
|
+
### Onboarding & UX
|
|
51
|
+
- First-run onboarding message; 429 guidance hint (add your own relay egress).
|
|
52
|
+
- `/freeflow test` to probe a relay; relay latency tracking with health badges in `/freeflow list`.
|
|
53
|
+
- Guided deploy with context picker + confirmation; post-deploy health check.
|
|
54
|
+
- Status clarity: current mode + state file path; log text filter; per-relay usage counters; throttled roll notifications.
|
|
55
|
+
|
|
56
|
+
### Reliability
|
|
57
|
+
- Relay state write-protection: before every save, the current state is snapshotted to `.bak`; if the main file is corrupted or missing, the backup is recovered automatically.
|
|
58
|
+
- New-user flow never seeds a fake relay; starts in direct mode with an empty pool.
|
|
59
|
+
|
|
60
|
+
## 1.5.1 - 2026-08-28
|
|
61
|
+
|
|
62
|
+
- Edge-sweep fixes: port 28180 with legacy 18080 dual-probe auto-migration, OMP/Pi compatibility.
|
package/README.md
CHANGED
|
@@ -262,7 +262,7 @@ Log rotation at 10MB. Clean, parseable, real-time HTTP lifecycle tracking.
|
|
|
262
262
|
|
|
263
263
|
This package stays thin. It ships three things: a model catalog, a relay proxy, and a log. There is no build step. The only runtime dependency is `undici`, which powers the upstream fetch agent. Thinking and prompt normalization stay with the host (`pi-ai`).
|
|
264
264
|
|
|
265
|
-
Current size: about 11.3k lines including tests.
|
|
265
|
+
Current size: about 11.3k lines including tests. The full suite (sandboxed, mocked network) and typecheck pass before every release — see CHANGELOG.md.
|
|
266
266
|
|
|
267
267
|
---
|
|
268
268
|
|
|
@@ -280,8 +280,11 @@ Yes. `/freeflow off` → direct. Add relays later to scale.
|
|
|
280
280
|
**What happens when I update to a new version?**
|
|
281
281
|
The local proxy daemon is shared across sessions on port 28180. On upgrade, the new extension
|
|
282
282
|
detects a stale daemon (mismatched internal version) and replaces it automatically — no manual
|
|
283
|
-
kill, no restart of other sessions required.
|
|
284
|
-
|
|
283
|
+
kill, no restart of other sessions required. Replacement only happens when the running daemon is
|
|
284
|
+
idle: sessions with in-flight requests are never interrupted (busy or newer daemons are reused
|
|
285
|
+
with a log note instead). If a daemon cannot be replaced (e.g. port held by an unrelated process),
|
|
286
|
+
it falls back to reusing it with a warning. To disable replacement entirely, set the no-kill env
|
|
287
|
+
to `1` before starting a session.
|
|
285
288
|
|
|
286
289
|
**Where's the normalizer?**
|
|
287
290
|
Deleted in 1.3.0. If zai/qwen/deepseek thinking broke before, it's fixed now because host handles it.
|
|
@@ -320,7 +323,7 @@ cd pi-freeflow
|
|
|
320
323
|
pnpm install
|
|
321
324
|
|
|
322
325
|
# run all three before opening a PR
|
|
323
|
-
pnpm test #
|
|
326
|
+
pnpm test # full suite; sandboxed + network-mocked
|
|
324
327
|
pnpm typecheck # tsc --noEmit, must pass clean
|
|
325
328
|
pnpm smoke # verifies extensions/index.ts loads without crashing
|
|
326
329
|
```
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.9.0",
|
|
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",
|
|
@@ -40,10 +40,11 @@
|
|
|
40
40
|
"extensions",
|
|
41
41
|
"src",
|
|
42
42
|
"README.md",
|
|
43
|
+
"CHANGELOG.md",
|
|
43
44
|
"LICENSE"
|
|
44
45
|
],
|
|
45
46
|
"scripts": {
|
|
46
|
-
"test": "node --experimental-strip-types --test --test-concurrency=1 test/**/*.test.ts",
|
|
47
|
+
"test": "node --experimental-strip-types --import ./test/setup.mjs --test --test-concurrency=1 test/**/*.test.ts",
|
|
47
48
|
"typecheck": "tsc --noEmit",
|
|
48
49
|
"smoke": "node --experimental-strip-types -e \"import('./extensions/index.ts').then(() => console.log('✓ Smoke test passed: extensions/index.ts loaded successfully')).catch(err => { console.error(err); process.exit(1); })\"",
|
|
49
50
|
"changeset": "changeset",
|
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
|
@@ -75,7 +75,7 @@ export const RATE_LIMIT_MAX: Record<Upstream, number> = {
|
|
|
75
75
|
};
|
|
76
76
|
|
|
77
77
|
// ── Whitelists & Security ───────────────────────────────────────────
|
|
78
|
-
export const ALLOWED_PATH_PATTERN = /^\/v1\/[a-zA-Z0-9/_.,\-?&=]*$/;
|
|
78
|
+
export const ALLOWED_PATH_PATTERN = /^\/v1\/[a-zA-Z0-9/_.,\-?&= %]*$/;
|
|
79
79
|
export const PATH_TRAVERSAL_PATTERN = /\.\./;
|
|
80
80
|
export const ALLOWED_METHODS = new Set(["GET", "POST", "OPTIONS", "HEAD"]);
|
|
81
81
|
|
|
@@ -95,10 +95,19 @@ export const STRIP_HEADERS = new Set([
|
|
|
95
95
|
"proxy-authorization",
|
|
96
96
|
]);
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
/** Env override that re-roots ALL pi-freeflow data files (tests/CI sandbox).
|
|
99
|
+
* Unset → ~/.pi/agent. */
|
|
100
|
+
export const DATA_DIR_ENV = "PI_FREEFLOW_DATA_DIR";
|
|
101
|
+
|
|
102
|
+
function dataDirOverride(): string | null {
|
|
103
|
+
const d = process.env[DATA_DIR_ENV];
|
|
104
|
+
return typeof d === "string" && d.trim() !== "" ? d : null;
|
|
105
|
+
}
|
|
99
106
|
|
|
100
107
|
export function resolveRelayStatePath(): string {
|
|
101
108
|
try {
|
|
109
|
+
const override = dataDirOverride();
|
|
110
|
+
if (override) return path.join(override, "pi-freeflow-relay-state.json");
|
|
102
111
|
return path.join(homedir(), ".pi", "agent", "pi-freeflow-relay-state.json");
|
|
103
112
|
} catch {
|
|
104
113
|
return path.join(
|
|
@@ -111,6 +120,8 @@ export function resolveRelayStatePath(): string {
|
|
|
111
120
|
|
|
112
121
|
export function resolveLogFilePath(): string {
|
|
113
122
|
try {
|
|
123
|
+
const override = dataDirOverride();
|
|
124
|
+
if (override) return path.join(override, "pi-freeflow.log");
|
|
114
125
|
return path.join(homedir(), ".pi", "agent", "pi-freeflow.log");
|
|
115
126
|
} catch {
|
|
116
127
|
return path.join(
|
|
@@ -123,6 +134,8 @@ export function resolveLogFilePath(): string {
|
|
|
123
134
|
|
|
124
135
|
export function resolveCatalogCachePath(): string {
|
|
125
136
|
try {
|
|
137
|
+
const override = dataDirOverride();
|
|
138
|
+
if (override) return path.join(override, "pi-freeflow-catalog-cache.json");
|
|
126
139
|
return path.join(
|
|
127
140
|
homedir(),
|
|
128
141
|
".pi",
|
|
@@ -140,6 +153,8 @@ export function resolveCatalogCachePath(): string {
|
|
|
140
153
|
|
|
141
154
|
export function resolveDebugStatePath(): string {
|
|
142
155
|
try {
|
|
156
|
+
const override = dataDirOverride();
|
|
157
|
+
if (override) return path.join(override, "pi-freeflow-debug.json");
|
|
143
158
|
return path.join(homedir(), ".pi", "agent", "pi-freeflow-debug.json");
|
|
144
159
|
} catch {
|
|
145
160
|
return path.join(
|
|
@@ -152,6 +167,8 @@ export function resolveDebugStatePath(): string {
|
|
|
152
167
|
|
|
153
168
|
export function resolveUpdateCachePath(): string {
|
|
154
169
|
try {
|
|
170
|
+
const override = dataDirOverride();
|
|
171
|
+
if (override) return path.join(override, "pi-freeflow-update.json");
|
|
155
172
|
return path.join(homedir(), ".pi", "agent", "pi-freeflow-update.json");
|
|
156
173
|
} catch {
|
|
157
174
|
return path.join(
|
|
@@ -164,6 +181,8 @@ export function resolveUpdateCachePath(): string {
|
|
|
164
181
|
|
|
165
182
|
export function resolveOnboardedFlagPath(): string {
|
|
166
183
|
try {
|
|
184
|
+
const override = dataDirOverride();
|
|
185
|
+
if (override) return path.join(override, "pi-freeflow-onboarded");
|
|
167
186
|
return path.join(homedir(), ".pi", "agent", "pi-freeflow-onboarded");
|
|
168
187
|
} catch {
|
|
169
188
|
return path.join(
|
|
@@ -184,6 +203,8 @@ export const UPDATE_CHECK_TTL_MS = 86_400_000;
|
|
|
184
203
|
// ── Security & Relay Validation ─────────────────────────────────────
|
|
185
204
|
/** Opt-out for tests/dev: when set to "1", relay URLs on http:// and private hosts are allowed. */
|
|
186
205
|
export const ALLOW_UNSAFE_RELAY_ENV = "PI_FREEFLOW_ALLOW_UNSAFE_RELAY";
|
|
206
|
+
/** Opt-out for the stale-daemon replace: when "1", a version-mismatched daemon is never killed. */
|
|
207
|
+
export const NO_KILL_ENV = ALLOW_UNSAFE_RELAY_ENV.replace("_ALLOW_UNSAFE_RELAY", "_NO_KILL");
|
|
187
208
|
/** Max request body the proxy buffers before responding 413. */
|
|
188
209
|
export const MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
189
210
|
/** Default timeout for upstream headers while proxying a request. */
|
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
|
@@ -24,12 +24,15 @@ export interface HealthData {
|
|
|
24
24
|
relays: HealthRelayInfo[];
|
|
25
25
|
catalog: number;
|
|
26
26
|
version: string;
|
|
27
|
+
/** In-flight proxied requests right now (stale-daemon replacement guard). */
|
|
28
|
+
activeRequests: number;
|
|
27
29
|
}
|
|
28
30
|
/**
|
|
29
31
|
* Collect current health snapshot.
|
|
30
32
|
* @param portOverride - actual listening port (defaults to config PORT)
|
|
33
|
+
* @param activeRequests - in-flight proxied requests (defaults to 0 for callers that do not track)
|
|
31
34
|
*/
|
|
32
|
-
export function getHealthData(portOverride?: number): HealthData {
|
|
35
|
+
export function getHealthData(portOverride?: number, activeRequests = 0): HealthData {
|
|
33
36
|
const state = getActiveRelayState();
|
|
34
37
|
const relays: HealthRelayInfo[] = (state.relays || []).map((r) => {
|
|
35
38
|
const h = getRelayHealth(r.url);
|
|
@@ -50,12 +53,14 @@ export function getHealthData(portOverride?: number): HealthData {
|
|
|
50
53
|
relays,
|
|
51
54
|
catalog: ALL_MODELS.length,
|
|
52
55
|
version: PKG_VERSION,
|
|
56
|
+
activeRequests,
|
|
53
57
|
};
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
function isLoopbackIP(ip: string): boolean {
|
|
57
61
|
if (!ip) return false;
|
|
58
|
-
const
|
|
62
|
+
const withoutZone = ip.split("%")[0];
|
|
63
|
+
const clean = withoutZone.startsWith("::ffff:") ? withoutZone.slice(7) : withoutZone;
|
|
59
64
|
return clean === "127.0.0.1" || clean === "::1" || clean === "localhost";
|
|
60
65
|
}
|
|
61
66
|
|
|
@@ -68,6 +73,7 @@ export function handleHealthRequest(
|
|
|
68
73
|
req: http.IncomingMessage,
|
|
69
74
|
res: http.ServerResponse,
|
|
70
75
|
portOverride?: number,
|
|
76
|
+
activeRequests = 0,
|
|
71
77
|
): boolean {
|
|
72
78
|
let pathname: string | null = null;
|
|
73
79
|
try {
|
|
@@ -95,7 +101,7 @@ export function handleHealthRequest(
|
|
|
95
101
|
return true;
|
|
96
102
|
}
|
|
97
103
|
|
|
98
|
-
const data = getHealthData(portOverride);
|
|
104
|
+
const data = getHealthData(portOverride, activeRequests);
|
|
99
105
|
const body = JSON.stringify(data);
|
|
100
106
|
res.writeHead(200, {
|
|
101
107
|
"content-type": "application/json",
|
package/src/index.ts
CHANGED
|
@@ -18,12 +18,12 @@ 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, PKG_VERSION, PORT } from "./config.ts";
|
|
21
|
+
import { HOST, LEGACY_PORT, NO_KILL_ENV, 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 { getDaemonVersion, isProxyAlive, killPortHolder, startProxy } from "./proxy.ts";
|
|
24
|
+
import { getDaemonHealth, getDaemonVersion, isProxyAlive, killPortHolder, startProxy } from "./proxy.ts";
|
|
25
25
|
import { resetRateLimits } from "./rate-limiter.ts";
|
|
26
|
-
import { checkForUpdateInBackground } from "./update-checker.ts";
|
|
26
|
+
import { checkForUpdateInBackground, compareVersions } from "./update-checker.ts";
|
|
27
27
|
import {
|
|
28
28
|
ensureRelay,
|
|
29
29
|
getActiveRelayState,
|
|
@@ -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,
|
|
@@ -161,6 +161,35 @@ export function buildProviderConfig(
|
|
|
161
161
|
};
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Stale-daemon replacement guard.
|
|
166
|
+
* Never interrupt a working shared daemon: a newer-version daemon is left
|
|
167
|
+
* running (downgrade case), an in-flight daemon is left running (busy case —
|
|
168
|
+
* its streams would die mid-flight), and NO_KILL_ENV disables replacement
|
|
169
|
+
* entirely. Only older, idle daemons are replaced.
|
|
170
|
+
*/
|
|
171
|
+
async function shouldReplaceDaemon(port: number, remoteVer: string): Promise<boolean> {
|
|
172
|
+
if (NO_KILL_ENV && process.env[NO_KILL_ENV] === "1") {
|
|
173
|
+
logInfo(
|
|
174
|
+
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (replacement disabled by env)`,
|
|
175
|
+
);
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
if (compareVersions(remoteVer, PKG_VERSION) > 0) {
|
|
179
|
+
logInfo(
|
|
180
|
+
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (newer daemon v${remoteVer} left running)`,
|
|
181
|
+
);
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
const health = await getDaemonHealth(port);
|
|
185
|
+
if (health && health.activeRequests !== undefined && health.activeRequests > 0) {
|
|
186
|
+
logInfo(
|
|
187
|
+
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (${health.activeRequests} active request${health.activeRequests === 1 ? "" : "s"} — not interrupted)`,
|
|
188
|
+
);
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
164
193
|
/**
|
|
165
194
|
* Main extension entrypoint
|
|
166
195
|
*/
|
|
@@ -180,7 +209,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
180
209
|
let alreadyRunning = await isProxyAlive(PORT);
|
|
181
210
|
if (alreadyRunning) {
|
|
182
211
|
const remoteVer = await getDaemonVersion(PORT);
|
|
183
|
-
if (remoteVer !== null && remoteVer !== PKG_VERSION) {
|
|
212
|
+
if (remoteVer !== null && remoteVer !== PKG_VERSION && (await shouldReplaceDaemon(PORT, remoteVer))) {
|
|
184
213
|
logWarn(`stale proxy daemon v${remoteVer} on :${PORT} (need v${PKG_VERSION}) — replacing`, {
|
|
185
214
|
remoteVer,
|
|
186
215
|
expected: PKG_VERSION,
|
|
@@ -214,7 +243,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
214
243
|
}
|
|
215
244
|
} else if (PORT !== LEGACY_PORT && (await isProxyAlive(LEGACY_PORT))) {
|
|
216
245
|
const remoteVer = await getDaemonVersion(LEGACY_PORT);
|
|
217
|
-
if (remoteVer !== null && remoteVer !== PKG_VERSION) {
|
|
246
|
+
if (remoteVer !== null && remoteVer !== PKG_VERSION && (await shouldReplaceDaemon(LEGACY_PORT, remoteVer))) {
|
|
218
247
|
logWarn(`stale legacy daemon v${remoteVer} on :${LEGACY_PORT} (need v${PKG_VERSION}) — replacing`, {
|
|
219
248
|
remoteVer,
|
|
220
249
|
expected: PKG_VERSION,
|
|
@@ -284,6 +313,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
284
313
|
if (await isProxyAlive(actualPort)) {
|
|
285
314
|
const v = await getDaemonVersion(actualPort);
|
|
286
315
|
if (v === null || v === PKG_VERSION) return;
|
|
316
|
+
if (!(await shouldReplaceDaemon(actualPort, v))) return;
|
|
287
317
|
logWarn(`proxy on :${actualPort} is stale v${v} (need v${PKG_VERSION}) — replacing`, {
|
|
288
318
|
remoteVer: v,
|
|
289
319
|
expected: PKG_VERSION,
|
|
@@ -299,7 +329,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
299
329
|
}
|
|
300
330
|
if (await isProxyAlive(PORT)) {
|
|
301
331
|
const v = await getDaemonVersion(PORT);
|
|
302
|
-
if (v !== null && v !== PKG_VERSION) {
|
|
332
|
+
if (v !== null && v !== PKG_VERSION && (await shouldReplaceDaemon(PORT, v))) {
|
|
303
333
|
logWarn(`proxy on :${PORT} is stale v${v} (need v${PKG_VERSION}) — replacing`, {
|
|
304
334
|
remoteVer: v,
|
|
305
335
|
expected: PKG_VERSION,
|
|
@@ -326,7 +356,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
326
356
|
}
|
|
327
357
|
if (PORT !== LEGACY_PORT && (await isProxyAlive(LEGACY_PORT))) {
|
|
328
358
|
const v = await getDaemonVersion(LEGACY_PORT);
|
|
329
|
-
if (v !== null && v !== PKG_VERSION) {
|
|
359
|
+
if (v !== null && v !== PKG_VERSION && (await shouldReplaceDaemon(LEGACY_PORT, v))) {
|
|
330
360
|
logWarn(`legacy proxy on :${LEGACY_PORT} is stale v${v} (need v${PKG_VERSION}) — replacing`, {
|
|
331
361
|
remoteVer: v,
|
|
332
362
|
expected: PKG_VERSION,
|
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
|
@@ -66,6 +66,7 @@ export function validatePath(rawUrl: string): URL | null {
|
|
|
66
66
|
if (PATH_TRAVERSAL_PATTERN.test(cleaned)) return null;
|
|
67
67
|
try {
|
|
68
68
|
const decoded = decodeURIComponent(cleaned);
|
|
69
|
+
if (PATH_TRAVERSAL_PATTERN.test(decoded)) return null;
|
|
69
70
|
if (decoded !== cleaned && !ALLOWED_PATH_PATTERN.test(`/${decoded}`)) {
|
|
70
71
|
return null;
|
|
71
72
|
}
|
|
@@ -125,7 +126,21 @@ export async function isProxyAlive(port: number): Promise<boolean> {
|
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
|
|
128
|
-
|
|
129
|
+
let activeRequests = 0;
|
|
130
|
+
|
|
131
|
+
/** Current in-flight proxied requests (guard for stale-daemon replacement). */
|
|
132
|
+
export function getActiveRequests(): number {
|
|
133
|
+
return activeRequests;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Fetch the full health snapshot from a running daemon.
|
|
138
|
+
* `activeRequests` is undefined on daemons older than the busy-tracking
|
|
139
|
+
* feature (1.9.0) — callers treat that as "cannot verify usage".
|
|
140
|
+
*/
|
|
141
|
+
export async function getDaemonHealth(
|
|
142
|
+
port: number,
|
|
143
|
+
): Promise<{ version: string | null; activeRequests: number | undefined } | null> {
|
|
129
144
|
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
130
145
|
try {
|
|
131
146
|
const res = await fetch(`http://${HOST}:${port}/_health`, {
|
|
@@ -133,17 +148,26 @@ export async function getDaemonVersion(port: number): Promise<string | null> {
|
|
|
133
148
|
});
|
|
134
149
|
if (!res.ok) return null;
|
|
135
150
|
const data: unknown = await res.json();
|
|
136
|
-
if (data && typeof data === "object"
|
|
137
|
-
const v =
|
|
138
|
-
|
|
151
|
+
if (data && typeof data === "object") {
|
|
152
|
+
const v =
|
|
153
|
+
"version" in data && typeof data.version === "string" && data.version
|
|
154
|
+
? data.version
|
|
155
|
+
: "";
|
|
156
|
+
const ar = "activeRequests" in data ? data.activeRequests : undefined;
|
|
157
|
+
return { version: v, activeRequests: typeof ar === "number" ? ar : undefined };
|
|
139
158
|
}
|
|
140
|
-
|
|
141
|
-
return "";
|
|
159
|
+
return null;
|
|
142
160
|
} catch {
|
|
143
161
|
return null;
|
|
144
162
|
}
|
|
145
163
|
}
|
|
146
164
|
|
|
165
|
+
/** Version of the daemon on `port`; "" when alive but pre-version-field, null when not alive. */
|
|
166
|
+
export async function getDaemonVersion(port: number): Promise<string | null> {
|
|
167
|
+
const h = await getDaemonHealth(port);
|
|
168
|
+
return h ? h.version : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
147
171
|
export async function killPortHolder(port: number): Promise<boolean> {
|
|
148
172
|
if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
|
|
149
173
|
try {
|
|
@@ -171,6 +195,7 @@ export async function killPortHolder(port: number): Promise<boolean> {
|
|
|
171
195
|
}) as string;
|
|
172
196
|
const pid = out.trim().split(/\s+/)[0];
|
|
173
197
|
if (!pid || !/^\d+$/.test(pid)) return false;
|
|
198
|
+
if (Number(pid) === process.pid) return false; // never self-kill (Unix) — matches Windows guard at 161
|
|
174
199
|
execSync(`kill -9 ${pid}`, { timeout: 3000, stdio: "ignore" });
|
|
175
200
|
return true;
|
|
176
201
|
} catch {
|
|
@@ -240,7 +265,7 @@ export function startProxy(
|
|
|
240
265
|
if (req.method === "GET" && reqPathname !== null && (reqPathname === "/_health" || reqPathname === "/health")) {
|
|
241
266
|
const addr = server.address();
|
|
242
267
|
const realPort = addr && typeof addr === "object" ? (addr as { port: number }).port : basePort;
|
|
243
|
-
if (handleHealthRequest(req, res, realPort)) return;
|
|
268
|
+
if (handleHealthRequest(req, res, realPort, getActiveRequests())) return;
|
|
244
269
|
}
|
|
245
270
|
if (req.method === "GET" && (reqPathname === "/v1/models" || reqPathname === "/v1/models/")) {
|
|
246
271
|
const alive = getAliveCatalog();
|
|
@@ -275,6 +300,11 @@ export function startProxy(
|
|
|
275
300
|
}
|
|
276
301
|
// Buffer request body to inspect model ID for upstream routing
|
|
277
302
|
const bodyChunks: Buffer[] = [];
|
|
303
|
+
activeRequests += 1;
|
|
304
|
+
res.on("close", () => {
|
|
305
|
+
activeRequests -= 1;
|
|
306
|
+
});
|
|
307
|
+
|
|
278
308
|
// Reject oversized bodies before buffering starts: the client declares
|
|
279
309
|
// the size in content-length, so no transfer cost is wasted.
|
|
280
310
|
const declaredLength = Number(req.headers["content-length"] ?? 0);
|
|
@@ -522,7 +552,7 @@ export function startProxy(
|
|
|
522
552
|
fwd["connection"] = "keep-alive";
|
|
523
553
|
|
|
524
554
|
const controller = new AbortController();
|
|
525
|
-
const timeoutId = setTimeout(() => controller.abort(),
|
|
555
|
+
const timeoutId = setTimeout(() => controller.abort(upstreamTimeoutError()), UPSTREAM_HEADER_TIMEOUT_MS);
|
|
526
556
|
const onClientClose = () => {
|
|
527
557
|
if (!res.writableEnded) controller.abort();
|
|
528
558
|
};
|
|
@@ -600,6 +630,7 @@ export function startProxy(
|
|
|
600
630
|
let settled = false;
|
|
601
631
|
|
|
602
632
|
const tryListen = async (port: number) => {
|
|
633
|
+
server.removeAllListeners("error");
|
|
603
634
|
server.once("error", async (err: NodeJS.ErrnoException) => {
|
|
604
635
|
if (settled) return;
|
|
605
636
|
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
|
}
|