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/CHANGELOG.md +19 -0
- package/README.md +5 -7
- package/package.json +1 -1
- package/src/catalog.ts +22 -4
- package/src/client.ts +45 -45
- package/src/cline-accounts.ts +48 -24
- package/src/cline-device-auth.ts +3 -4
- package/src/commands.ts +4 -3
- package/src/config.ts +275 -29
- package/src/health.ts +117 -116
- package/src/lease.ts +57 -53
- package/src/logger.ts +357 -357
- package/src/models.ts +13 -54
- package/src/opencode-fingerprint.ts +348 -117
- package/src/probe.ts +31 -10
- package/src/proxy.ts +173 -73
- package/src/relay-state.ts +97 -43
- package/src/relay.ts +752 -714
- package/src/stream-pipe.ts +5 -5
- package/src/system-ca-fetch.ts +8 -2
- package/src/token.ts +66 -38
- package/src/tool-args.ts +26 -26
- package/src/tool-translation.ts +266 -61
- package/src/toolcall-audit.ts +2 -2
- package/src/types.ts +4 -2
- package/src/update-checker.ts +88 -88
- package/src/update-core.ts +182 -137
- package/src/updater.ts +161 -103
- package/src/upstream-health.ts +66 -21
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 {
|
|
302
|
+
export function _resetLiveOpenCodeVersionForTest(): void {
|
|
303
|
+
liveOpenCodeVersion = null;
|
|
304
|
+
liveOpenCodeVersionSource = null;
|
|
305
|
+
diskMemo = null;
|
|
306
|
+
binaryMemo = null;
|
|
307
|
+
}
|
|
168
308
|
|
|
169
309
|
/**
|
|
170
|
-
*
|
|
171
|
-
* env override > in-process live version >
|
|
172
|
-
* Never throws, never touches the
|
|
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
|
-
|
|
317
|
+
function currentOpenCodeVersion(): string {
|
|
175
318
|
const override = toOpenCodeUserAgent(process.env[OPENCODE_USER_AGENT_ENV]);
|
|
176
|
-
if (override) return override;
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
182
|
-
return
|
|
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
|
|
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
|
|
357
|
+
return getOpenCodeUserAgent();
|
|
206
358
|
} catch { return getOpenCodeUserAgent(); }
|
|
207
359
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
.
|
|
211
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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":
|
|
513
|
+
"x-opencode-project": resolveOpenCodeProjectLive(cwd),
|
|
267
514
|
"x-opencode-session": sessionId,
|
|
268
515
|
"x-opencode-session-id": sessionId,
|
|
269
|
-
"x-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
url: string;
|
|
17
|
+
label?: string;
|
|
18
|
+
healthy: boolean;
|
|
19
|
+
cooldownUntil: number;
|
|
20
|
+
consecutiveFailures: number;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
interface HealthData {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
67
|
+
[K in "activeRequests" | "sseRate" | "sseDegraded" | "lastBytesAt"]?: HealthData[K] | undefined;
|
|
67
68
|
} & {
|
|
68
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
req: http.IncomingMessage,
|
|
159
|
+
res: http.ServerResponse,
|
|
160
|
+
portOverride?: number,
|
|
161
|
+
activeRequests = 0,
|
|
161
162
|
): boolean {
|
|
162
|
-
|
|
163
|
+
if (req.method !== "GET") return false;
|
|
163
164
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
}
|