pi-freeflow 1.4.11 → 1.5.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.4.11",
4
+ "version": "1.5.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",
@@ -45,11 +45,19 @@
45
45
  "scripts": {
46
46
  "test": "node --experimental-strip-types --test --test-concurrency=1 test/**/*.test.ts",
47
47
  "typecheck": "tsc --noEmit",
48
- "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); })\""
48
+ "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
+ "changeset": "changeset",
50
+ "docs:dev": "vitepress dev docs",
51
+ "docs:build": "vitepress build docs"
49
52
  },
50
53
  "devDependencies": {
54
+ "@changesets/cli": "^2.27.0",
51
55
  "@earendil-works/pi-coding-agent": "^0.84.3",
52
56
  "@types/node": "^22.13.9",
53
- "typescript": "^5.8.2"
57
+ "typescript": "^5.8.2",
58
+ "vitepress": "^1.6.4"
59
+ },
60
+ "dependencies": {
61
+ "undici": "^8.10.0"
54
62
  }
55
63
  }
package/src/catalog.ts CHANGED
@@ -34,8 +34,7 @@ import type {
34
34
  /**
35
35
  * Pruned model IDs that must never re-enter the catalog via disk cache or upstream merge.
36
36
  */
37
- const DEAD_MODEL_IDS = new Set<string>(["deepseek-v4-flash-free", "x-preview-f-free"]);
38
-
37
+ export const DEAD_MODEL_IDS = new Set<string>(["deepseek-v4-flash-free", "x-preview-f-free"]);
39
38
  /**
40
39
  * In-memory cache of currently active/available free models.
41
40
  * Initialized with all 21 verified models for 0ms instant availability.
@@ -170,6 +169,7 @@ export function enrichModelDef(raw: RawModelItem, source: Upstream): RegisteredM
170
169
  /**
171
170
  * Read cached catalog data from disk if valid and unexpired.
172
171
  * Filters out pruned dead IDs so stale disk entries never repopulate the catalog.
172
+ * Preserves etag for conditional If-None-Match requests.
173
173
  */
174
174
  export function readCatalogCache(): CatalogCacheData | null {
175
175
  try {
@@ -192,6 +192,7 @@ export function readCatalogCache(): CatalogCacheData | null {
192
192
 
193
193
  /**
194
194
  * Atomically write catalog cache data to disk using temporary file + rename.
195
+ * Persists etag alongside models for subsequent If-None-Match conditional requests.
195
196
  */
196
197
  export function writeCatalogCache(data: CatalogCacheData): void {
197
198
  try {
@@ -209,12 +210,13 @@ export function writeCatalogCache(data: CatalogCacheData): void {
209
210
 
210
211
  /**
211
212
  * Refresh free model catalog from OpenCode Zen and KiloCode Gateway endpoints.
212
- * Falls back gracefully to cached or static models if network requests fail.
213
+ * Uses ETag conditional requests (If-None-Match) to avoid re-merging unchanged
214
+ * catalogs; a 304 Not Modified response skips merge and returns the in-memory
215
+ * catalog unchanged. Falls back gracefully to cached or static models if network
216
+ * requests fail.
213
217
  */
214
218
  export async function refreshCatalog(force = false): Promise<RegisteredModel[]> {
215
- // Thin provider: no live fetch subagents must not hit upstream directly
216
- // (proxy-only). Host Pi/OMP owns dynamic discovery via fetchDynamicModels (24h).
217
- // We only serve disk cache if fresh, otherwise static 21-model aliveCatalog.
219
+ // Thin provider: serve fresh disk cache instantly when valid
218
220
  const disk = readCatalogCache();
219
221
  if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
220
222
  const age = Date.now() - (disk.timestamp ?? 0);
@@ -222,7 +224,79 @@ export async function refreshCatalog(force = false): Promise<RegisteredModel[]>
222
224
  aliveCatalog = disk.models.filter((m) => !DEAD_MODEL_IDS.has(m.id));
223
225
  return aliveCatalog;
224
226
  }
225
- // Stale cache still better than empty — return it without network (filtered)
227
+ }
228
+
229
+ // Resolve etag from fresh or stale cache for conditional request
230
+ let cachedEtag: string | undefined = disk?.etag;
231
+ let staleForEtag: CatalogCacheData | null = disk;
232
+ if (!cachedEtag) {
233
+ try {
234
+ if (fs.existsSync(CATALOG_CACHE_FILE)) {
235
+ const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
236
+ const stale = JSON.parse(raw) as CatalogCacheData;
237
+ cachedEtag = stale.etag;
238
+ staleForEtag = stale;
239
+ }
240
+ } catch (err) {
241
+ logDebug("Failed reading stale catalog cache for etag", { error: String(err) });
242
+ }
243
+ }
244
+
245
+ // Attempt conditional fetch with If-None-Match when we have an etag
246
+ if (cachedEtag || force) {
247
+ try {
248
+ const headers: Record<string, string> = { ...opencodeHeaders() };
249
+ if (cachedEtag) {
250
+ headers["If-None-Match"] = cachedEtag;
251
+ }
252
+ const res = await fetch(`${OPENCODE_API_URL}/models`, { headers });
253
+ if (res.status === 304) {
254
+ // Not modified — skip merge, extend timestamp to avoid tight loop
255
+ if (staleForEtag && Array.isArray(staleForEtag.models)) {
256
+ try {
257
+ writeCatalogCache({ ...staleForEtag, timestamp: Date.now() });
258
+ } catch {}
259
+ }
260
+ return aliveCatalog;
261
+ }
262
+ if (res.ok) {
263
+ const newEtag = res.headers.get("etag") ?? res.headers.get("ETag") ?? cachedEtag;
264
+ const body: unknown = await res.json();
265
+ let rawList: RawModelItem[] = [];
266
+ if (Array.isArray(body)) {
267
+ rawList = body as RawModelItem[];
268
+ } else if (body !== null && typeof body === "object" && "data" in body) {
269
+ const dataVal = body.data as unknown;
270
+ if (Array.isArray(dataVal)) {
271
+ rawList = dataVal as RawModelItem[];
272
+ }
273
+ }
274
+ if (rawList.length > 0) {
275
+ const fresh = rawList.map((r) => enrichModelDef(r, "opencode"));
276
+ const merged = mergeCatalog(aliveCatalog, fresh);
277
+ aliveCatalog = merged;
278
+ writeCatalogCache({
279
+ timestamp: Date.now(),
280
+ opencode: fresh.map((m) => m.id),
281
+ kilo: staleForEtag?.kilo ?? [],
282
+ models: merged,
283
+ etag: newEtag ?? cachedEtag,
284
+ });
285
+ return aliveCatalog;
286
+ }
287
+ // Empty payload but 200 — treat as no-op, return current
288
+ if (newEtag && newEtag !== cachedEtag && staleForEtag) {
289
+ writeCatalogCache({ ...staleForEtag, timestamp: Date.now(), etag: newEtag });
290
+ }
291
+ return aliveCatalog;
292
+ }
293
+ } catch (err) {
294
+ logDebug("Conditional catalog fetch failed, falling back to cache", { error: String(err) });
295
+ }
296
+ }
297
+
298
+ // Stale cache still better than empty — return it without network (filtered)
299
+ if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
226
300
  const filtered = disk.models.filter((m) => !DEAD_MODEL_IDS.has(m.id));
227
301
  if (filtered.length >= 21) {
228
302
  aliveCatalog = filtered;
package/src/commands.ts CHANGED
@@ -4,17 +4,17 @@
4
4
  * log viewing, debug level configuration, and live catalog refreshing.
5
5
  */
6
6
 
7
- import { spawn } from "node:child_process";
8
- import { readFileSync } from "node:fs";
9
- import path from "node:path";
10
- import { fileURLToPath } from "node:url";
11
- import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
12
- import { DEBUG_STATE_FILE, DEFAULT_RELAY_URL, LOG_FILE } from "./config.ts";
13
- import {
14
- compareVersions,
15
- fetchLatestVersion,
16
- getCachedUpdate,
17
- isLinkedInstall,
7
+ import { spawn } from "node:child_process";
8
+ import { readFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
12
+ import { DEBUG_STATE_FILE, DEFAULT_RELAY_URL, LOG_FILE } from "./config.ts";
13
+ import {
14
+ compareVersions,
15
+ fetchLatestVersion,
16
+ getCachedUpdate,
17
+ isLinkedInstall,
18
18
  } from "./update-checker.ts";
19
19
  import {
20
20
  deployCloudflareWorker,
@@ -60,6 +60,10 @@ import type {
60
60
  export function updateStatusBar(ui?: ExtensionUIContext): void {
61
61
  if (!ui) return;
62
62
  const relayState = getActiveRelayState();
63
+ if (relayState.hideWidget) {
64
+ ui.setStatus("freeflow", undefined);
65
+ return;
66
+ }
63
67
  if (relayState.enabled && relayState.relays.length > 0) {
64
68
  const label = shortRelayLabel(relayState.url);
65
69
  const idx = Math.max(
@@ -76,64 +80,68 @@ export function updateStatusBar(ui?: ExtensionUIContext): void {
76
80
  }
77
81
  }
78
82
 
79
- function getLocalVersion(): string {
80
- try {
81
- const thisDir = path.dirname(fileURLToPath(import.meta.url));
82
- const pkgPath = path.join(thisDir, "..", "package.json");
83
- const raw = readFileSync(pkgPath, "utf8");
84
- const pkg = JSON.parse(raw) as { version?: string };
85
- if (typeof pkg.version === "string" && pkg.version.trim().length > 0) {
86
- return pkg.version.trim();
87
- }
88
- return "0.0.0";
89
- } catch {
90
- return "0.0.0";
91
- }
92
- }
93
-
94
- function spawnWithProgress(
95
- cmd: string,
96
- args: string[],
97
- ctx: ExtensionContext,
98
- ): Promise<number> {
99
- return new Promise((resolve) => {
100
- try {
101
- const child = spawn(cmd, args, {
102
- shell: process.platform === "win32",
103
- stdio: "pipe",
104
- });
105
- child.stdout?.on("data", (d: Buffer) => {
106
- const s = String(d).trim();
107
- if (s) ctx.ui.notify(s, "info");
108
- });
109
- child.stderr?.on("data", (d: Buffer) => {
110
- const s = String(d).trim();
111
- if (s) ctx.ui.notify(s, "info");
112
- });
113
- child.on("error", (err: Error) => {
114
- ctx.ui.notify(`spawn ${cmd} failed: ${err.message}`, "warning");
115
- resolve(1);
116
- });
117
- child.on("close", (code: number | null) => resolve(code ?? 0));
118
- } catch (e) {
119
- ctx.ui.notify(`spawn ${cmd} failed: ${(e as Error).message}`, "warning");
120
- resolve(1);
121
- }
122
- });
123
- }
124
-
83
+ function getLocalVersion(): string {
84
+ try {
85
+ const thisDir = path.dirname(fileURLToPath(import.meta.url));
86
+ const pkgPath = path.join(thisDir, "..", "package.json");
87
+ const raw = readFileSync(pkgPath, "utf8");
88
+ const pkg = JSON.parse(raw) as { version?: string };
89
+ if (typeof pkg.version === "string" && pkg.version.trim().length > 0) {
90
+ return pkg.version.trim();
91
+ }
92
+ return "0.0.0";
93
+ } catch {
94
+ return "0.0.0";
95
+ }
96
+ }
97
+
98
+ function spawnWithProgress(
99
+ cmd: string,
100
+ args: string[],
101
+ ctx: ExtensionContext,
102
+ ): Promise<number> {
103
+ return new Promise((resolve) => {
104
+ try {
105
+ const child = spawn(cmd, args, {
106
+ shell: process.platform === "win32",
107
+ stdio: "pipe",
108
+ });
109
+ child.stdout?.on("data", (d: Buffer) => {
110
+ const s = String(d).trim();
111
+ if (s) ctx.ui.notify(s, "info");
112
+ });
113
+ child.stderr?.on("data", (d: Buffer) => {
114
+ const s = String(d).trim();
115
+ if (s) ctx.ui.notify(s, "info");
116
+ });
117
+ child.on("error", (err: Error) => {
118
+ ctx.ui.notify(`spawn ${cmd} failed: ${err.message}`, "warning");
119
+ resolve(1);
120
+ });
121
+ child.on("close", (code: number | null) => resolve(code ?? 0));
122
+ } catch (e) {
123
+ ctx.ui.notify(`spawn ${cmd} failed: ${(e as Error).message}`, "warning");
124
+ resolve(1);
125
+ }
126
+ });
127
+ }
128
+
125
129
  export function createCommandSpec(
126
130
  _pi: ExtensionAPI,
127
131
  onCatalogRefreshed?: (models: RegisteredModel[]) => void,
128
132
  ): Omit<RegisteredCommand, "name"> {
129
133
  return {
130
134
  description:
131
- "Relay egress: auto | on | off | status | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | logs [level] [n] | debug on|off | refresh | update | deploy vercel | deploy cloudflare | deploy deno",
135
+ "Relay egress: auto | on | off | hide | show | widget hide/show | status | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | logs [level] [n] | debug on|off | refresh | update | deploy vercel | deploy cloudflare | deploy deno",
132
136
  getArgumentCompletions: (prefix: string) =>
133
137
  [
134
138
  "auto",
135
139
  "on",
136
140
  "off",
141
+ "hide",
142
+ "show",
143
+ "widget hide",
144
+ "widget show",
137
145
  "status",
138
146
  "add",
139
147
  "list",
@@ -402,73 +410,83 @@ export function createCommandSpec(
402
410
  setActiveRelayState(relayState);
403
411
  persist();
404
412
  flash();
405
- } else if (sub === "status") {
406
- flash();
407
- try {
408
- const local = getLocalVersion();
409
- let latest: string | null = null;
410
- const cached = getCachedUpdate();
411
- if (cached && typeof cached.latest === "string") {
412
- latest = cached.latest;
413
- }
414
- if (latest && compareVersions(latest, local) > 0) {
415
- ctx.ui.notify(
416
- `Update available: ${local} -> ${latest} - run /freeflow update`,
417
- "info",
418
- );
419
- }
420
- } catch {
421
- // swallow status banner is best-effort
422
- }
423
- } else if (sub === "update") {
424
- if (isLinkedInstall()) {
425
- ctx.ui.notify(
426
- "LINK install (D:/github_repo/pi-freeflow) is live - just omp restart, no npm update needed.",
427
- "info",
428
- );
429
- } else {
430
- try {
431
- ctx.ui.notify("Checking for updates…", "info");
432
- let latest: string | null = null;
433
- try {
434
- latest = await fetchLatestVersion();
435
- } catch {
436
- latest = null;
437
- }
438
- if (!latest) {
439
- const cached = getCachedUpdate();
440
- if (cached && typeof cached.latest === "string") {
441
- latest = cached.latest;
442
- }
443
- }
444
- if (!latest) {
445
- ctx.ui.notify("Could not check latest version (offline?)", "warning");
446
- } else {
447
- const local = getLocalVersion();
448
- const cmp = compareVersions(latest, local);
449
- if (cmp <= 0) {
450
- ctx.ui.notify(`Already on latest (v${local})`, "info");
451
- } else {
452
- ctx.ui.notify(`Update available: v${local} → v${latest} — updating…`, "info");
453
- let code = await spawnWithProgress("omp", ["plugin", "update", "pi-freeflow"], ctx);
454
- if (code !== 0) {
455
- ctx.ui.notify(`omp update exited ${code}, trying npm…`, "info");
456
- code = await spawnWithProgress("npm", ["i", "-g", "pi-freeflow@latest"], ctx);
457
- }
458
- if (code === 0) {
459
- ctx.ui.notify(`Updated to ${latest}, restart OMP`, "info");
460
- } else {
461
- ctx.ui.notify(
462
- `Update failed (exit ${code})try manually: npm i -g pi-freeflow@latest`,
463
- "warning",
464
- );
465
- }
466
- }
467
- }
468
- } catch (e) {
469
- ctx.ui.notify(`Update failed: ${(e as Error).message} try manually: npm i -g pi-freeflow@latest`, "warning");
470
- }
471
- }
413
+ } else if (sub === "hide" || (sub === "widget" && rest === "hide")) {
414
+ relayState.hideWidget = true;
415
+ persist();
416
+ updateStatusBar(ctx.ui);
417
+ ctx.ui.notify("Widget hidden use /freeflow show or /freeflow widget show to restore", "info");
418
+ } else if (sub === "show" || (sub === "widget" && rest === "show")) {
419
+ relayState.hideWidget = false;
420
+ persist();
421
+ flash();
422
+ ctx.ui.notify("Widget shown", "info");
423
+ } else if (sub === "status") {
424
+ flash();
425
+ try {
426
+ const local = getLocalVersion();
427
+ let latest: string | null = null;
428
+ const cached = getCachedUpdate();
429
+ if (cached && typeof cached.latest === "string") {
430
+ latest = cached.latest;
431
+ }
432
+ if (latest && compareVersions(latest, local) > 0) {
433
+ ctx.ui.notify(
434
+ `Update available: ${local} -> ${latest} - run /freeflow update`,
435
+ "info",
436
+ );
437
+ }
438
+ } catch {
439
+ // swallow status banner is best-effort
440
+ }
441
+ } else if (sub === "update") {
442
+ if (isLinkedInstall()) {
443
+ ctx.ui.notify(
444
+ "LINK install (D:/github_repo/pi-freeflow) is live - just omp restart, no npm update needed.",
445
+ "info",
446
+ );
447
+ } else {
448
+ try {
449
+ ctx.ui.notify("Checking for updates…", "info");
450
+ let latest: string | null = null;
451
+ try {
452
+ latest = await fetchLatestVersion();
453
+ } catch {
454
+ latest = null;
455
+ }
456
+ if (!latest) {
457
+ const cached = getCachedUpdate();
458
+ if (cached && typeof cached.latest === "string") {
459
+ latest = cached.latest;
460
+ }
461
+ }
462
+ if (!latest) {
463
+ ctx.ui.notify("Could not check latest version (offline?)", "warning");
464
+ } else {
465
+ const local = getLocalVersion();
466
+ const cmp = compareVersions(latest, local);
467
+ if (cmp <= 0) {
468
+ ctx.ui.notify(`Already on latest (v${local})`, "info");
469
+ } else {
470
+ ctx.ui.notify(`Update available: v${local} → v${latest} — updating…`, "info");
471
+ let code = await spawnWithProgress("omp", ["plugin", "update", "pi-freeflow"], ctx);
472
+ if (code !== 0) {
473
+ ctx.ui.notify(`omp update exited ${code}, trying npm…`, "info");
474
+ code = await spawnWithProgress("npm", ["i", "-g", "pi-freeflow@latest"], ctx);
475
+ }
476
+ if (code === 0) {
477
+ ctx.ui.notify(`Updated to ${latest}, restart OMP`, "info");
478
+ } else {
479
+ ctx.ui.notify(
480
+ `Update failed (exit ${code}) — try manually: npm i -g pi-freeflow@latest`,
481
+ "warning",
482
+ );
483
+ }
484
+ }
485
+ }
486
+ } catch (e) {
487
+ ctx.ui.notify(`Update failed: ${(e as Error).message} — try manually: npm i -g pi-freeflow@latest`, "warning");
488
+ }
489
+ }
472
490
  } else if (sub === "list") {
473
491
  showList();
474
492
  } else if (sub === "add") {
@@ -611,12 +629,14 @@ export function createCommandSpec(
611
629
  let filterLevel: LogLevel | null = null;
612
630
  let filterReqId: string | null = null;
613
631
  let count = 25;
632
+ const rawTokens = rawRest ? rawRest.split(/\s+/) : [];
633
+ const isFollow = rawTokens.includes("--follow") || rawTokens.includes("-f");
614
634
 
615
635
  if (sub === "trace" && rawRest) {
616
- filterReqId = rawRest.split(/\s+/)[0];
636
+ filterReqId = rawTokens.filter((t) => t !== "--follow" && t !== "-f")[0] || null;
617
637
  } else if (rawRest) {
618
- const tokens = rawRest.split(/\s+/);
619
- for (const t of tokens) {
638
+ for (const t of rawTokens) {
639
+ if (t === "--follow" || t === "-f") continue;
620
640
  const lower = t.toLowerCase();
621
641
  if (lower in LOG_LEVEL_ORDER) {
622
642
  filterLevel = lower as LogLevel;
@@ -655,6 +675,18 @@ export function createCommandSpec(
655
675
  "warning",
656
676
  );
657
677
  }
678
+ if (isFollow) {
679
+ const _t = setInterval(() => {
680
+ try {
681
+ const tail = readRecentLogs(filterLevel, filterReqId, count);
682
+ if (tail.lines.length > 0) {
683
+ ctx.ui.notify(tail.lines.join("\n"), "info");
684
+ }
685
+ } catch {}
686
+ }, 1000);
687
+ // @ts-ignore allow unref to not block process exit in CLI
688
+ _t.unref?.();
689
+ }
658
690
  return;
659
691
  }
660
692
 
@@ -663,6 +695,18 @@ export function createCommandSpec(
663
695
  `${header}\n\n${result.lines.join("\n")}`,
664
696
  "info",
665
697
  );
698
+ if (isFollow) {
699
+ const _t2 = setInterval(() => {
700
+ try {
701
+ const tail = readRecentLogs(filterLevel, filterReqId, count);
702
+ if (tail.lines.length > 0) {
703
+ ctx.ui.notify(tail.lines.join("\n"), "info");
704
+ }
705
+ } catch {}
706
+ }, 1000);
707
+ // @ts-ignore allow unref to not block process exit in CLI
708
+ _t2.unref?.();
709
+ }
666
710
  } catch (e) {
667
711
  ctx.ui.notify(
668
712
  `Could not read log file: ${(e as Error).message}`,
package/src/health.ts ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Health endpoint for pi-freeflow proxy
3
+ * Loopback-only GET /_health (and alias /health) returning relay health snapshot.
4
+ */
5
+
6
+ import type * as http from "node:http";
7
+ import { ALL_MODELS } from "./models.ts";
8
+ import { getActiveRelayState, getRelayHealth, isRelayHealthy } from "./relay-state.ts";
9
+ import { PORT } from "./config.ts";
10
+
11
+ export interface HealthRelayInfo {
12
+ url: string;
13
+ label?: string;
14
+ healthy: boolean;
15
+ cooldownUntil: number;
16
+ consecutiveFailures: number;
17
+ }
18
+
19
+ export interface HealthData {
20
+ port: number;
21
+ active: string;
22
+ mode: string;
23
+ enabled: boolean;
24
+ relays: HealthRelayInfo[];
25
+ catalog: number;
26
+ }
27
+
28
+ /**
29
+ * Collect current health snapshot.
30
+ * @param portOverride - actual listening port (defaults to config PORT)
31
+ */
32
+ export function getHealthData(portOverride?: number): HealthData {
33
+ const state = getActiveRelayState();
34
+ const relays: HealthRelayInfo[] = (state.relays || []).map((r) => {
35
+ const h = getRelayHealth(r.url);
36
+ const healthy = isRelayHealthy(r.url);
37
+ return {
38
+ url: r.url,
39
+ label: r.label,
40
+ healthy,
41
+ cooldownUntil: h?.cooldownUntil ?? 0,
42
+ consecutiveFailures: h?.consecutiveFailures ?? 0,
43
+ };
44
+ });
45
+ return {
46
+ port: portOverride ?? PORT,
47
+ active: state.url || "",
48
+ mode: (state.mode as string) ?? "auto",
49
+ enabled: Boolean(state.enabled),
50
+ relays,
51
+ catalog: ALL_MODELS.length,
52
+ };
53
+ }
54
+
55
+ function isLoopbackIP(ip: string): boolean {
56
+ if (!ip) return false;
57
+ const clean = ip.startsWith("::ffff:") ? ip.slice(7) : ip;
58
+ return clean === "127.0.0.1" || clean === "::1" || clean === "localhost";
59
+ }
60
+
61
+ function getClientIPFromReq(req: http.IncomingMessage): string {
62
+ const addr = (req.socket as unknown as { remoteAddress?: string })?.remoteAddress;
63
+ if (!addr) return "unknown";
64
+ return addr.startsWith("::ffff:") ? addr.slice(7) : addr;
65
+ }
66
+
67
+ /**
68
+ * Handle loopback health requests.
69
+ * Returns true if request was a health endpoint (handled, response already sent).
70
+ * Returns false if not a health path (caller should continue).
71
+ */
72
+ export function handleHealthRequest(
73
+ req: http.IncomingMessage,
74
+ res: http.ServerResponse,
75
+ portOverride?: number,
76
+ ): boolean {
77
+ let pathname: string | null = null;
78
+ try {
79
+ pathname = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
80
+ } catch {
81
+ return false;
82
+ }
83
+
84
+ const isHealthPath =
85
+ pathname === "/_health" || pathname === "/health" || pathname.endsWith("/health");
86
+ if (req.method !== "GET" || !isHealthPath) {
87
+ return false;
88
+ }
89
+
90
+ const clientIP = getClientIPFromReq(req);
91
+ if (!isLoopbackIP(clientIP)) {
92
+ const body = JSON.stringify({ error: "forbidden" });
93
+ res.writeHead(403, { "content-type": "application/json", "content-length": Buffer.byteLength(body) });
94
+ res.end(body);
95
+ return true;
96
+ }
97
+
98
+ const data = getHealthData(portOverride);
99
+ const body = JSON.stringify(data);
100
+ res.writeHead(200, {
101
+ "content-type": "application/json",
102
+ "content-length": Buffer.byteLength(body),
103
+ });
104
+ res.end(body);
105
+ return true;
106
+ }