pi-freeflow 1.42.0 → 1.43.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/src/config.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * Configuration and path resolution for pi-freeflow
3
3
  */
4
4
 
5
+ import { spawnSync } from "node:child_process";
5
6
  import { createHash, randomBytes } from "node:crypto";
6
7
  import { homedir } from "node:os";
7
8
  import path from "node:path";
@@ -25,6 +26,55 @@ export const UPSTREAM_OPENCODE = "https://opencode.ai/inference";
25
26
  export const KILO_CHAT_URL = "https://api.kilo.ai/api/gateway/chat/completions";
26
27
  export const KILO_RESPONSES_URL = "https://api.kilo.ai/api/gateway/responses";
27
28
  export const OPENCODE_API_URL = `${UPSTREAM_OPENCODE}/v1`;
29
+ /**
30
+ * OpenAI-compat Zen lane — the DEFAULT lane for chat completions.
31
+ * Live truth (v1 1.19.4 MITM, SG): the official CLI addresses the free tier
32
+ * here (`POST https://opencode.ai/inference/openai/v1/chat/completions`),
33
+ * not on the plain /v1 chat path. Same gateway, same Bearer public
34
+ * credential, but the path carries the extra `/openai` segment and the body
35
+ * carries `stream_options: { include_usage: true }`.
36
+ * The plain `/inference/v1/chat/completions` path stays as the fallback when
37
+ * the compat lane answers 404/405. Relay probes address the compat lane via
38
+ * OPENCODE_OPENAI_COMPAT_PATH (the x-relay-path the workers forward as
39
+ * target + path).
40
+ */
41
+ export const OPENCODE_OPENAI_COMPAT_URL = `${UPSTREAM_OPENCODE}/openai/v1/chat/completions`;
42
+ export const OPENCODE_OPENAI_COMPAT_PATH = "/inference/openai/v1/chat/completions";
43
+
44
+ /**
45
+ * Origin (scheme + host) of the OpenCode gateway. UPSTREAM_OPENCODE above is
46
+ * the origin plus the `/inference` base path; joining a `/inference/...`
47
+ * upstream path onto it would double the base (`/inference/inference/...`),
48
+ * so callers that already hold an upstream-rooted path join it here instead.
49
+ */
50
+ export const OPENCODE_ORIGIN = "https://opencode.ai";
51
+
52
+ /**
53
+ * Default-lane upstream path for a request path. Chat completions ride
54
+ * the openai-compat lane (live CLI truth); every other path (responses,
55
+ * messages, models) keeps its plain mapping. The plain chat path stays as
56
+ * the fallback when the compat lane answers 404/405.
57
+ *
58
+ * Accepts EITHER form because production `validatePath` resolves the local
59
+ * `/v1/...` path against the `/inference` base, so `target.pathname` already
60
+ * arrives upstream-rooted (`/inference/v1/chat/completions`): a leading
61
+ * `/inference` base is normalized off before mapping, and an already-compat
62
+ * or unknown path passes through untouched instead of being sliced blindly.
63
+ * Idempotent: feeding its own output back returns it unchanged.
64
+ */
65
+ export function zenUpstreamPathFor(localOrUpstreamPathname: string): string {
66
+ const local = localOrUpstreamPathname.startsWith("/inference/")
67
+ ? localOrUpstreamPathname.slice("/inference".length)
68
+ : localOrUpstreamPathname;
69
+ if (local === "/v1/chat/completions") return OPENCODE_OPENAI_COMPAT_PATH;
70
+ if (local.startsWith("/v1/")) return `/inference${local}`;
71
+ return localOrUpstreamPathname;
72
+ }
73
+
74
+ /** Full upstream URL for a local request path on the default Zen lane. */
75
+ export function zenUpstreamUrlFor(localPathname: string): string {
76
+ return `${OPENCODE_ORIGIN}${zenUpstreamPathFor(localPathname)}`;
77
+ }
28
78
  // Cline serves chat completions only — responses-path requests are translated
29
79
  // to chat upstream and back (see tool-translation.ts). Direct-only: Cline
30
80
  // traffic never rides the relay pool and carries a per-user bearer token
@@ -83,8 +133,6 @@ export const OPENCODE_USER_AGENT_ENV = "PI_FREEFLOW_OPENCODE_USER_AGENT";
83
133
  /** Owned UA probe cache file (sibling of the update-check cache). */
84
134
  export const OPENCODE_VERSION_CACHE_ENV = "PI_FREEFLOW_OPENCODE_VERSION_CACHE";
85
135
 
86
- export const OPENCODE_VERSION = OPENCODE_VERSION_FALLBACK;
87
- export const OPENCODE_USER_AGENT = `opencode/${OPENCODE_VERSION}`;
88
136
  export const OPENCODE_CLIENT = "cli";
89
137
 
90
138
  function compareVersionParts(a: string, b: string): number {
@@ -115,6 +163,92 @@ export function toOpenCodeUserAgent(version: unknown): string | null {
115
163
  return `opencode/${bare}`;
116
164
  }
117
165
 
166
+ /** Memo TTL for the installed-binary probe: at most one spawn per minute. */
167
+ export const OPENCODE_BINARY_VERSION_TTL_MS = 60_000;
168
+ /** Where an observed live OpenCode version came from. */
169
+ export type OpenCodeVersionSource = "registry" | "binary";
170
+
171
+ /**
172
+ * Parse `opencode --version` output into a bare semver, else null.
173
+ * Accepts the shapes the CLIs print (`2.2.0`, `v2.2.0`, `opencode-ai 1.19.4`)
174
+ * and floors through isSupportedOpenCodeVersion, so anything below 1.17.0
175
+ * (or unparseable) never drives the wire UA.
176
+ */
177
+ export function parseOpenCodeBinaryVersion(output: unknown): string | null {
178
+ if (typeof output !== "string") return null;
179
+ const match = output.match(/(\d+\.\d+\.\d+)/);
180
+ if (!match) return null;
181
+ const version: string | undefined = match[1];
182
+ if (version === undefined || !isSupportedOpenCodeVersion(version)) return null;
183
+ return version;
184
+ }
185
+
186
+ /** Minimal spawnSync result surface the binary probe reads. */
187
+ export interface OpenCodeSpawnResult {
188
+ status: number | null;
189
+ stdout?: unknown;
190
+ stderr?: unknown;
191
+ error?: unknown;
192
+ }
193
+ /** spawnSync-compatible injector for the binary probe (tests stub this). */
194
+ export type OpenCodeSpawnSync = (
195
+ command: string,
196
+ args: readonly string[],
197
+ options: { encoding: "utf8"; timeout: number; windowsHide: boolean; shell?: boolean },
198
+ ) => OpenCodeSpawnResult;
199
+
200
+ function defaultOpenCodeSpawn(
201
+ command: string,
202
+ args: readonly string[],
203
+ options: { encoding: "utf8"; timeout: number; windowsHide: boolean; shell?: boolean },
204
+ ): OpenCodeSpawnResult {
205
+ // Single-string form under a Windows shell: resolves *.cmd shims the same
206
+ // way isCommandAvailable does; direct exec elsewhere.
207
+ if (process.platform === "win32") {
208
+ return spawnSync(`${command} ${args.join(" ")}`, {
209
+ encoding: options.encoding,
210
+ timeout: options.timeout,
211
+ windowsHide: options.windowsHide,
212
+ shell: true,
213
+ }) as unknown as OpenCodeSpawnResult;
214
+ }
215
+ return spawnSync(command, [...args], options) as unknown as OpenCodeSpawnResult;
216
+ }
217
+
218
+ let binaryMemo: { key: string; version: string | null; probedAt: number } | null = null;
219
+
220
+ /**
221
+ * Probe the locally installed `opencode` binary (`--version`, 10s timeout).
222
+ * Offline-safe by construction: no network, try/catch, null when the binary
223
+ * is missing, fails, times out, or prints an unsupported version. Results
224
+ * (including misses) are memoized ~60s so the request path never spawns
225
+ * more than once a minute. Pass `spawnImpl` to stub the spawn in tests —
226
+ * never rely on a real PATH binary there (CI has none).
227
+ */
228
+ export function probeInstalledOpenCodeVersion(opts: {
229
+ execPath?: string;
230
+ timeoutMs?: number;
231
+ spawnImpl?: OpenCodeSpawnSync;
232
+ } = {}): string | null {
233
+ const execPath = opts.execPath ?? "opencode";
234
+ const timeoutMs = opts.timeoutMs ?? 10_000;
235
+ const now = Date.now();
236
+ if (binaryMemo && binaryMemo.key === execPath && now - binaryMemo.probedAt < OPENCODE_BINARY_VERSION_TTL_MS) {
237
+ return binaryMemo.version;
238
+ }
239
+ let version: string | null = null;
240
+ try {
241
+ const spawn = opts.spawnImpl ?? defaultOpenCodeSpawn;
242
+ const res = spawn(execPath, ["--version"], { encoding: "utf8", timeout: timeoutMs, windowsHide: true });
243
+ if (res && res.error === undefined && res.status === 0) {
244
+ const text = [res.stdout, res.stderr].filter((part): part is string => typeof part === "string").join("\n");
245
+ version = parseOpenCodeBinaryVersion(text);
246
+ }
247
+ } catch { version = null; }
248
+ binaryMemo = { key: execPath, version, probedAt: now };
249
+ return version;
250
+ }
251
+
118
252
  function openCodeVersionCachePath(): string {
119
253
  const override = process.env[OPENCODE_VERSION_CACHE_ENV];
120
254
  if (typeof override === "string" && override.trim() !== "") return override;
@@ -149,6 +283,7 @@ function writeOpenCodeVersionCache(version: string): void {
149
283
  }
150
284
 
151
285
  let liveOpenCodeVersion: string | null = null;
286
+ let liveOpenCodeVersionSource: OpenCodeVersionSource | null = null;
152
287
  /** Memoized disk read: path -> { version, fileCheckedAt } so opencodeHeaders() stays sync and cheap. */
153
288
  let diskMemo: { path: string; version: string | null; readAt: number } | null = null;
154
289
  const DISK_MEMO_TTL_MS = 60_000;
@@ -164,22 +299,38 @@ function memoizedDiskVersion(): string | null {
164
299
  }
165
300
 
166
301
  /** Test-only: reset the in-process live version (cache file untouched). */
167
- export function _resetLiveOpenCodeVersionForTest(): void { liveOpenCodeVersion = null; diskMemo = null; }
302
+ export function _resetLiveOpenCodeVersionForTest(): void {
303
+ liveOpenCodeVersion = null;
304
+ liveOpenCodeVersionSource = null;
305
+ diskMemo = null;
306
+ binaryMemo = null;
307
+ }
168
308
 
169
309
  /**
170
- * Synchronous UA getter — offline-safe by construction. Precedence:
171
- * env override > in-process live version > fresh disk cache > pinned fallback.
172
- * Never throws, never touches the network (disk re-read at most once a minute).
310
+ * Current OpenCode version for the wire UA — the single precedence chain:
311
+ * env override > installed binary (1.x line) > in-process live version >
312
+ * fresh disk cache > pinned fallback. Never throws, never touches the
313
+ * network (disk re-read at most once a minute, binary probe at most once a
314
+ * minute). An installed 2.x binary never drives the compat-lane UA — it is
315
+ * skipped here and surfaces via getLiveOpenCodeVersion for logs only.
173
316
  */
174
- export function getOpenCodeUserAgent(): string {
317
+ function currentOpenCodeVersion(): string {
175
318
  const override = toOpenCodeUserAgent(process.env[OPENCODE_USER_AGENT_ENV]);
176
- if (override) return override;
177
- if (liveOpenCodeVersion && isSupportedOpenCodeVersion(liveOpenCodeVersion)) {
178
- return `opencode/${liveOpenCodeVersion}`;
179
- }
319
+ if (override) return override.slice("opencode/".length);
320
+ const installed = probeInstalledOpenCodeVersion();
321
+ if (installed && installed.split(".")[0] === "1") return installed;
322
+ if (liveOpenCodeVersion && isSupportedOpenCodeVersion(liveOpenCodeVersion)) return liveOpenCodeVersion;
180
323
  const disk = memoizedDiskVersion();
181
- if (disk) return `opencode/${disk}`;
182
- return OPENCODE_USER_AGENT;
324
+ if (disk) return disk;
325
+ return OPENCODE_VERSION_FALLBACK;
326
+ }
327
+
328
+ /**
329
+ * Synchronous UA getter — the single User-Agent implementation every lane
330
+ * sends (offline-safe by construction). Always the verbatim CLI full shape.
331
+ */
332
+ export function getOpenCodeUserAgent(): string {
333
+ return buildOpenCodeUserAgent(currentOpenCodeVersion());
183
334
  }
184
335
 
185
336
  /**
@@ -190,7 +341,7 @@ export function getOpenCodeUserAgent(): string {
190
341
  */
191
342
  export async function refreshOpenCodeUserAgent(fetchImpl: typeof fetch = fetch): Promise<string> {
192
343
  const override = toOpenCodeUserAgent(process.env[OPENCODE_USER_AGENT_ENV]);
193
- if (override) return override;
344
+ if (override) return getOpenCodeUserAgent();
194
345
  try {
195
346
  const res = await fetchImpl("https://registry.npmjs.org/opencode-ai/latest", {
196
347
  signal: AbortSignal.timeout(10_000),
@@ -200,15 +351,92 @@ export async function refreshOpenCodeUserAgent(fetchImpl: typeof fetch = fetch):
200
351
  const version = (body as Record<string, unknown>)?.version;
201
352
  if (!isSupportedOpenCodeVersion(version)) return getOpenCodeUserAgent();
202
353
  liveOpenCodeVersion = version as string;
354
+ liveOpenCodeVersionSource = "registry";
203
355
  writeOpenCodeVersionCache(version as string);
204
356
  diskMemo = null;
205
- return `opencode/${version}`;
357
+ return getOpenCodeUserAgent();
206
358
  } catch { return getOpenCodeUserAgent(); }
207
359
  }
208
- // OpenCode project ID: 40-character sha1 hex hash
209
- export const OPENCODE_PROJECT = createHash("sha1")
210
- .update("git-remote:github.com/anomalyco/opencode")
211
- .digest("hex");
360
+ /**
361
+ * Live version already fetched in-process, else the installed binary, else
362
+ * the pinned fallback. The installed 2.x line surfaces here (and in logs)
363
+ * while the compat-lane UA keeps the 1.x line — major-2 never auto-switches
364
+ * the wire shape. No network; the binary probe is memoized ~60s.
365
+ */
366
+ export function getLiveOpenCodeVersion(): string {
367
+ if (liveOpenCodeVersion) return liveOpenCodeVersion;
368
+ return probeInstalledOpenCodeVersion() ?? OPENCODE_VERSION_FALLBACK;
369
+ }
370
+ /**
371
+ * Where the live version came from: `registry` when refresh populated it,
372
+ * `binary` when it reflects the installed binary, null on the pure fallback.
373
+ */
374
+ export function getOpenCodeVersionSource(): OpenCodeVersionSource | null {
375
+ if (liveOpenCodeVersion) return liveOpenCodeVersionSource;
376
+ if (probeInstalledOpenCodeVersion() !== null) return "binary";
377
+ return null;
378
+ }
379
+
380
+ /**
381
+ * Build the OpenCode User-Agent string: always the verbatim CLI full shape
382
+ * (`opencode/<version> ai-sdk/provider-utils/4.0.23 runtime/bun/1.4.4`).
383
+ * The legacy bare and channel shapes are gone with the A/B profiles.
384
+ */
385
+ export function buildOpenCodeUserAgent(version: string): string {
386
+ return `opencode/${version} ai-sdk/provider-utils/4.0.23 runtime/bun/1.4.4`;
387
+ }
388
+
389
+ const STABLE_SESSION_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/;
390
+ const STABLE_SESSION_CAP = 1000;
391
+ const stableSessionCache = new Map<string, string>();
392
+ const TIME48_MASK = (1n << 48n) - 1n;
393
+
394
+ /**
395
+ * Stable per-conversation session id. A valid `ses_*` key passes through
396
+ * verbatim; "" allocates a fresh random id; anything else maps deterministically
397
+ * via sha256("pi-freeflow\\0" + key): 48-bit time field from the digest with the
398
+ * descending counter pinned at 1, plus 14 base62 chars from later digest bytes.
399
+ * Results are memoized in an LRU (cap 1000, hit moves to back); `now` is accepted
400
+ * for signature compatibility and ignored so the same key always maps the same.
401
+ */
402
+ export function stableOpenCodeSessionId(key: string, now = Date.now()): string {
403
+ void now;
404
+ if (STABLE_SESSION_RE.test(key)) return key;
405
+ if (key === "") return createOpenCodeSessionId();
406
+ const hit = stableSessionCache.get(key);
407
+ if (hit !== undefined) {
408
+ stableSessionCache.delete(key);
409
+ stableSessionCache.set(key, hit);
410
+ return hit;
411
+ }
412
+ const digest = createHash("sha256").update(`pi-freeflow\0${key}`).digest();
413
+ const time48 = (BigInt(digest[0]!) << 40n) | (BigInt(digest[1]!) << 32n) | (BigInt(digest[2]!) << 24n) | (BigInt(digest[3]!) << 16n) | (BigInt(digest[4]!) << 8n) | BigInt(digest[5]!);
414
+ const time = ((~(time48 * 0x1000n + 1n)) & TIME48_MASK).toString(16).padStart(12, "0");
415
+ let rand = "";
416
+ for (let i = 0; i < 14; i++) rand += ID_CHARS[digest[(6 + i) % digest.length]! % 62];
417
+ const id = `ses_${time}${rand}`;
418
+ stableSessionCache.set(key, id);
419
+ if (stableSessionCache.size > STABLE_SESSION_CAP) {
420
+ const oldest = stableSessionCache.keys().next();
421
+ if (!oldest.done) stableSessionCache.delete(oldest.value);
422
+ }
423
+ return id;
424
+ }
425
+
426
+ /** Test-only: clear the stable session memo. */
427
+ export function _resetStableSessionCacheForTest(): void { stableSessionCache.clear(); }
428
+
429
+ /** Test-only: current stable session memo size (proves the LRU cap). */
430
+ export function _stableSessionCacheSizeForTest(): number { return stableSessionCache.size; }
431
+
432
+ /** Test-only: whether a key is currently memoized (proves LRU eviction). */
433
+ export function _stableSessionHasKeyForTest(key: string): boolean { return stableSessionCache.has(key); }
434
+ /** The literal `global` project the CLI sends outside a repo. */
435
+ export const OPENCODE_PROJECT_GLOBAL = "global";
436
+ /** sha1 hex digest of a working directory (what the CLI sends as the project). */
437
+ export function openCodeProjectForCwd(cwd: string): string {
438
+ return createHash("sha1").update(cwd).digest("hex");
439
+ }
212
440
 
213
441
  const ID_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
214
442
  let idTimestamp = 0;
@@ -251,23 +479,41 @@ export function createOpenCodeRequestId(): string {
251
479
  return `msg_${createOpenCodeId(false)}`;
252
480
  }
253
481
 
254
- export const OPENCODE_SESSION = createOpenCodeSessionId();
482
+ /**
483
+ * Live project header: the CLI sends `global` outside a repo and the sha1 of
484
+ * the working directory inside one. Without a cwd to hash, `global` is the
485
+ * truthful value — never the fixed pin above.
486
+ */
487
+ export function resolveOpenCodeProjectLive(cwd?: string): string {
488
+ if (typeof cwd === "string" && cwd.length > 0) return openCodeProjectForCwd(cwd);
489
+ return OPENCODE_PROJECT_GLOBAL;
490
+ }
255
491
 
256
- export function opencodeHeaders(): Record<string, string> {
257
- const sessionId = OPENCODE_SESSION;
258
- // Real OpenCode CLI (captured 2.0.28) does NOT send x-opencode-request on
259
- // /inference/v1. Emitting msg_* there is treated as a non-OpenCode client and
260
- // returns 403 FreeTierError — keep the helper for ID-shape tests, but omit
261
- // the header on the wire.
492
+ /**
493
+ * Live-truth OpenCode headers (v1 1.19.4 MITM, SG): full UA, Bearer public,
494
+ * client cli, per-conversation ses_* session, fresh msg_* request id per
495
+ * prepare, and the cwd-derived project. Affinity headers are NEVER sent on
496
+ * the opencode lane (upstream treats them as a non-OpenCode signal); they
497
+ * are gated to non-opencode lanes only (see sanitizeHeaders callers).
498
+ *
499
+ * @param sessionKey conversation-scoped key reused within a run
500
+ * (stableOpenCodeSessionId maps it deterministically; a valid ses_* passes
501
+ * through verbatim). Omitted/empty allocates a fresh id for this call.
502
+ * @param cwd working directory for the project header; omitted → `global`.
503
+ */
504
+ export function opencodeHeaders(sessionKey?: string, cwd?: string): Record<string, string> {
505
+ const sessionId =
506
+ typeof sessionKey === "string" && sessionKey.length > 0
507
+ ? stableOpenCodeSessionId(sessionKey)
508
+ : createOpenCodeSessionId();
262
509
  return {
263
510
  Authorization: "Bearer public",
264
511
  "User-Agent": getOpenCodeUserAgent(),
265
512
  "x-opencode-client": OPENCODE_CLIENT,
266
- "x-opencode-project": OPENCODE_PROJECT,
513
+ "x-opencode-project": resolveOpenCodeProjectLive(cwd),
267
514
  "x-opencode-session": sessionId,
268
515
  "x-opencode-session-id": sessionId,
269
- "x-session-affinity": sessionId,
270
- "x-session-id": sessionId,
516
+ "x-opencode-request": createOpenCodeRequestId(),
271
517
  };
272
518
  }
273
519
 
package/src/health.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type * as http from "node:http";
8
+ import { Buffer } from "node:buffer";
8
9
  import { ALL_MODELS } from "./models.ts";
9
10
  import { getActiveRelayState, getRelayHealth, isRelayHealthy } from "./relay-state.ts";
10
11
  import { getLastActivityAt, getLeaseCount, getLeaseSnapshot } from "./lease.ts";
@@ -12,45 +13,45 @@ import { PKG_VERSION, PORT } from "./config.ts";
12
13
  import { getLastForwardedByteAt, getMidStreamFaultStats, getSseStats } from "./stream-pipe.ts";
13
14
 
14
15
  interface HealthRelayInfo {
15
- url: string;
16
- label?: string;
17
- healthy: boolean;
18
- cooldownUntil: number;
19
- consecutiveFailures: number;
16
+ url: string;
17
+ label?: string;
18
+ healthy: boolean;
19
+ cooldownUntil: number;
20
+ consecutiveFailures: number;
20
21
  }
21
22
 
22
23
  interface HealthData {
23
- port: number;
24
- active: string;
25
- mode: string;
26
- enabled: boolean;
27
- relays: HealthRelayInfo[];
28
- catalog: number;
29
- version: string;
30
- /** In-flight proxied requests right now (stale-daemon replacement guard). */
31
- activeRequests: number;
32
- /** Clients holding a live lease (detached-daemon GC). */
33
- clients: number;
34
- /** clientId -> lastSeenAt for every live lease. */
35
- leases: Record<string, number>;
36
- /** Last time any request was proxied (request-touch for legacy clients). */
37
- lastActivityAt: number;
38
- /** Failed-SSE rolling window: failures in the last 20 streams. */
39
- sseFailed: number;
40
- /** Streams recorded in the current rolling window (max 20). */
41
- sseTotal: number;
42
- /** Failure rate over the window (0 when empty). */
43
- sseRate: number;
44
- /** True when the window holds enough samples and the rate exceeds 50%. */
45
- sseDegraded: boolean;
46
- /** Last time any stream byte was forwarded (busy-bypass quiet check), 0 when no stream yet. */
47
- lastBytesAt: number;
48
- /** Mid-stream turns the upstream model itself failed (model fault, never a relay fault). */
49
- midStreamModelFaults: number;
50
- /** Mid-stream turns held on a retriable rate-limit frame (whether or not the rescue served). */
51
- midStreamRateLimitFaults: number;
52
- /** Mid-stream turns cut by the relay/transport (relay fault, rotated away from). */
53
- midStreamRelayTruncations: number;
24
+ port: number;
25
+ active: string;
26
+ mode: string;
27
+ enabled: boolean;
28
+ relays: HealthRelayInfo[];
29
+ catalog: number;
30
+ version: string;
31
+ /** In-flight proxied requests right now (stale-daemon replacement guard). */
32
+ activeRequests: number;
33
+ /** Clients holding a live lease (detached-daemon GC). */
34
+ clients: number;
35
+ /** clientId -> lastSeenAt for every live lease. */
36
+ leases: Record<string, number>;
37
+ /** Last time any request was proxied (request-touch for legacy clients). */
38
+ lastActivityAt: number;
39
+ /** Failed-SSE rolling window: failures in the last 20 streams. */
40
+ sseFailed: number;
41
+ /** Streams recorded in the current rolling window (max 20). */
42
+ sseTotal: number;
43
+ /** Failure rate over the window (0 when empty). */
44
+ sseRate: number;
45
+ /** True when the window holds enough samples and the rate exceeds 50%. */
46
+ sseDegraded: boolean;
47
+ /** Last time any stream byte was forwarded (busy-bypass quiet check), 0 when no stream yet. */
48
+ lastBytesAt: number;
49
+ /** Mid-stream turns the upstream model itself failed (model fault, never a relay fault). */
50
+ midStreamModelFaults: number;
51
+ /** Mid-stream turns held on a retriable rate-limit frame (whether or not the rescue served). */
52
+ midStreamRateLimitFaults: number;
53
+ /** Mid-stream turns cut by the relay/transport (relay fault, rotated away from). */
54
+ midStreamRelayTruncations: number;
54
55
  }
55
56
 
56
57
  /**
@@ -63,9 +64,9 @@ interface HealthData {
63
64
  * for unversioned responses. The key list must match what getDaemonHealth parses.
64
65
  */
65
66
  export type DaemonHealthSnapshot = {
66
- [K in "activeRequests" | "sseRate" | "sseDegraded" | "lastBytesAt"]?: HealthData[K] | undefined;
67
+ [K in "activeRequests" | "sseRate" | "sseDegraded" | "lastBytesAt"]?: HealthData[K] | undefined;
67
68
  } & {
68
- version: string | null;
69
+ version: string | null;
69
70
  } | null;
70
71
 
71
72
  /**
@@ -74,41 +75,41 @@ export type DaemonHealthSnapshot = {
74
75
  * @param activeRequests - in-flight proxied requests (defaults to 0 for callers that do not track)
75
76
  */
76
77
  export function getHealthData(portOverride?: number, activeRequests = 0): HealthData {
77
- const state = getActiveRelayState();
78
- const relays: HealthRelayInfo[] = (state.relays || []).map((r) => {
79
- const h = getRelayHealth(r.url);
80
- const healthy = isRelayHealthy(r.url);
81
- return {
82
- url: r.url,
83
- label: r.label,
84
- healthy,
85
- cooldownUntil: h?.cooldownUntil ?? 0,
86
- consecutiveFailures: h?.consecutiveFailures ?? 0,
87
- };
88
- });
89
- const sse = getSseStats();
90
- const midStreamFaults = getMidStreamFaultStats();
91
- return {
92
- port: portOverride ?? PORT,
93
- active: state.url || "",
94
- mode: (state.mode as string) ?? "auto",
95
- enabled: Boolean(state.enabled),
96
- relays,
97
- catalog: ALL_MODELS.length,
98
- version: PKG_VERSION,
99
- activeRequests,
100
- clients: getLeaseCount(),
101
- leases: getLeaseSnapshot(),
102
- lastActivityAt: getLastActivityAt(),
103
- sseFailed: sse.failures,
104
- sseTotal: sse.total,
105
- sseRate: sse.rate,
106
- sseDegraded: sse.degraded,
107
- lastBytesAt: getLastForwardedByteAt(),
108
- midStreamModelFaults: midStreamFaults.modelFaults,
109
- midStreamRateLimitFaults: midStreamFaults.rateLimitFaults,
110
- midStreamRelayTruncations: midStreamFaults.relayTruncations,
111
- };
78
+ const state = getActiveRelayState();
79
+ const relays: HealthRelayInfo[] = (state.relays || []).map((r) => {
80
+ const h = getRelayHealth(r.url);
81
+ const healthy = isRelayHealthy(r.url);
82
+ return {
83
+ url: r.url,
84
+ label: r.label,
85
+ healthy,
86
+ cooldownUntil: h?.cooldownUntil ?? 0,
87
+ consecutiveFailures: h?.consecutiveFailures ?? 0,
88
+ };
89
+ });
90
+ const sse = getSseStats();
91
+ const midStreamFaults = getMidStreamFaultStats();
92
+ return {
93
+ port: portOverride ?? PORT,
94
+ active: state.url || "",
95
+ mode: (state.mode as string) ?? "auto",
96
+ enabled: Boolean(state.enabled),
97
+ relays,
98
+ catalog: ALL_MODELS.length,
99
+ version: PKG_VERSION,
100
+ activeRequests,
101
+ clients: getLeaseCount(),
102
+ leases: getLeaseSnapshot(),
103
+ lastActivityAt: getLastActivityAt(),
104
+ sseFailed: sse.failures,
105
+ sseTotal: sse.total,
106
+ sseRate: sse.rate,
107
+ sseDegraded: sse.degraded,
108
+ lastBytesAt: getLastForwardedByteAt(),
109
+ midStreamModelFaults: midStreamFaults.modelFaults,
110
+ midStreamRateLimitFaults: midStreamFaults.rateLimitFaults,
111
+ midStreamRelayTruncations: midStreamFaults.relayTruncations,
112
+ };
112
113
  }
113
114
 
114
115
  /**
@@ -124,20 +125,20 @@ export function getHealthData(portOverride?: number, activeRequests = 0): Health
124
125
  * still fails.
125
126
  */
126
127
  export function isDaemonIdentityBody(port: number, body: unknown): boolean {
127
- if (!body || typeof body !== "object" || Array.isArray(body)) return false;
128
- if (!("port" in body) || typeof body.port !== "number" || body.port !== port) return false;
129
- if ("service" in body) return body.service === "pi-freeflow" && "ok" in body && body.ok === true;
130
- return "version" in body && typeof body.version === "string" && body.version !== "";
128
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false;
129
+ if (!("port" in body) || typeof body.port !== "number" || body.port !== port) return false;
130
+ if ("service" in body) return body.service === "pi-freeflow" && "ok" in body && body.ok === true;
131
+ return "version" in body && typeof body.version === "string" && body.version !== "";
131
132
  }
132
133
 
133
134
  /**
134
135
  * Check whether an IP address is a loopback address (127.0.0.1, ::1, localhost).
135
136
  */
136
137
  export function isLoopbackIP(ip: string): boolean {
137
- if (!ip) return false;
138
- const withoutZone = ip.split("%")[0];
139
- const clean = withoutZone.startsWith("::ffff:") ? withoutZone.slice(7) : withoutZone;
140
- return clean === "127.0.0.1" || clean === "::1" || clean === "localhost";
138
+ if (!ip) return false;
139
+ const withoutZone = ip.split("%")[0];
140
+ const clean = withoutZone.startsWith("::ffff:") ? withoutZone.slice(7) : withoutZone;
141
+ return clean === "127.0.0.1" || clean === "::1" || clean === "localhost";
141
142
  }
142
143
 
143
144
  /**
@@ -154,43 +155,43 @@ export function isLoopbackIP(ip: string): boolean {
154
155
  * here, so this handler does not repeat the auth check.
155
156
  */
156
157
  export function handleHealthRequest(
157
- req: http.IncomingMessage,
158
- res: http.ServerResponse,
159
- portOverride?: number,
160
- activeRequests = 0,
158
+ req: http.IncomingMessage,
159
+ res: http.ServerResponse,
160
+ portOverride?: number,
161
+ activeRequests = 0,
161
162
  ): boolean {
162
- if (req.method !== "GET") return false;
163
+ if (req.method !== "GET") return false;
163
164
 
164
- let reqPathname: string | null = null;
165
- try {
166
- reqPathname = new URL(req.url ?? "/", `http://127.0.0.1`).pathname;
167
- } catch {
168
- return false;
169
- }
170
- if (reqPathname === null) return false;
171
- if (reqPathname !== "/_health" && reqPathname !== "/health") return false;
165
+ let reqPathname: string | null = null;
166
+ try {
167
+ reqPathname = new URL(req.url ?? "/", `http://127.0.0.1`).pathname;
168
+ } catch {
169
+ return false;
170
+ }
171
+ if (reqPathname === null) return false;
172
+ if (reqPathname !== "/_health" && reqPathname !== "/health") return false;
172
173
 
173
- const clientIP = req.socket.remoteAddress ?? "";
174
- if (!isLoopbackIP(clientIP)) {
175
- res.writeHead(403, { "content-type": "application/json" });
176
- res.end(JSON.stringify({ error: "loopback only" }));
177
- return true;
178
- }
174
+ const clientIP = req.socket.remoteAddress ?? "";
175
+ if (!isLoopbackIP(clientIP)) {
176
+ res.writeHead(403, { "content-type": "application/json" });
177
+ res.end(JSON.stringify({ error: "loopback only" }));
178
+ return true;
179
+ }
179
180
 
180
- const body =
181
- reqPathname === "/health"
182
- ? JSON.stringify({
183
- ok: true,
184
- service: "pi-freeflow",
185
- port: portOverride ?? PORT,
186
- version: PKG_VERSION,
187
- })
188
- : JSON.stringify(getHealthData(portOverride, activeRequests), null, 2);
189
- res.writeHead(200, {
190
- "content-type": "application/json",
191
- "content-length": Buffer.byteLength(body),
192
- "cache-control": "no-store",
193
- });
194
- res.end(body);
195
- return true;
181
+ const body =
182
+ reqPathname === "/health"
183
+ ? JSON.stringify({
184
+ ok: true,
185
+ service: "pi-freeflow",
186
+ port: portOverride ?? PORT,
187
+ version: PKG_VERSION,
188
+ })
189
+ : JSON.stringify(getHealthData(portOverride, activeRequests), null, 2);
190
+ res.writeHead(200, {
191
+ "content-type": "application/json",
192
+ "content-length": Buffer.byteLength(body),
193
+ "cache-control": "no-store",
194
+ });
195
+ res.end(body);
196
+ return true;
196
197
  }