@juspay/neurolink 9.54.4 → 9.54.5
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 +6 -0
- package/dist/browser/neurolink.min.js +305 -305
- package/dist/cli/commands/proxy.js +333 -31
- package/dist/lib/proxy/routingPolicy.d.ts +4 -29
- package/dist/lib/proxy/routingPolicy.js +14 -46
- package/dist/lib/proxy/usageStats.d.ts +0 -1
- package/dist/lib/proxy/usageStats.js +0 -7
- package/dist/lib/server/routes/claudeProxyRoutes.js +55 -95
- package/dist/lib/types/proxy.d.ts +2 -12
- package/dist/proxy/routingPolicy.d.ts +4 -29
- package/dist/proxy/routingPolicy.js +14 -46
- package/dist/proxy/usageStats.d.ts +0 -1
- package/dist/proxy/usageStats.js +0 -7
- package/dist/server/routes/claudeProxyRoutes.js +55 -95
- package/dist/types/proxy.d.ts +2 -12
- package/package.json +1 -1
|
@@ -201,27 +201,200 @@ async function isProxyHealthy(host, port, timeoutMs) {
|
|
|
201
201
|
return false;
|
|
202
202
|
}
|
|
203
203
|
}
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Stable entrypoint for launchd
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
/**
|
|
208
|
+
* Path to a small trampoline script that the plist invokes.
|
|
209
|
+
* The trampoline re-resolves `neurolink` via PATH on every launch,
|
|
210
|
+
* so launchd never gets pinned to a version-specific store path.
|
|
211
|
+
*/
|
|
212
|
+
const TRAMPOLINE_DIR = join(homedir(), ".neurolink", "bin");
|
|
213
|
+
const TRAMPOLINE_PATH = join(TRAMPOLINE_DIR, "neurolink-proxy");
|
|
214
|
+
/**
|
|
215
|
+
* Verify a candidate bin path actually runs by invoking `--version` on it.
|
|
216
|
+
* Returns the version string on success, or undefined on any failure.
|
|
217
|
+
*/
|
|
218
|
+
function probeBinVersion(binPath) {
|
|
219
|
+
try {
|
|
220
|
+
const { execFileSync } = _require("node:child_process");
|
|
221
|
+
const out = execFileSync(binPath, ["--version"], {
|
|
222
|
+
encoding: "utf8",
|
|
223
|
+
timeout: 5_000,
|
|
224
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
225
|
+
}).trim();
|
|
226
|
+
return out || undefined;
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Write (or overwrite) the trampoline shell script.
|
|
234
|
+
*
|
|
235
|
+
* Defensive design: the trampoline tries multiple candidates in order and
|
|
236
|
+
* only `exec`s one whose `--version` check succeeds. If every PATH-based
|
|
237
|
+
* candidate is broken (stale shims, missing packages), it falls back to the
|
|
238
|
+
* baked-in `node + script` path that was verified to work at install time.
|
|
239
|
+
*/
|
|
240
|
+
function writeTrampoline() {
|
|
241
|
+
const { writeFileSync, mkdirSync, existsSync, chmodSync } = _require("fs");
|
|
242
|
+
if (!existsSync(TRAMPOLINE_DIR)) {
|
|
243
|
+
mkdirSync(TRAMPOLINE_DIR, { recursive: true });
|
|
244
|
+
}
|
|
245
|
+
// Baked-in fallback: the specific node + JS script currently running
|
|
246
|
+
// (guaranteed to work, since we ARE running). Used only if all PATH-based
|
|
247
|
+
// candidates fail their --version probe.
|
|
248
|
+
const bakedNode = process.execPath;
|
|
249
|
+
const bakedScript = process.argv[1] ?? join(__dirname, "..", "index.js");
|
|
250
|
+
// Shell-escape the baked paths (they shouldn't contain quotes in practice,
|
|
251
|
+
// but be safe for paths with spaces).
|
|
252
|
+
const shEscape = (s) => `'${s.replace(/'/g, "'\\''")}'`;
|
|
253
|
+
const script = `#!/bin/sh
|
|
254
|
+
# Auto-generated by \`neurolink proxy install\` — do not edit.
|
|
255
|
+
# Resolves a working neurolink binary on every launchd invocation so the
|
|
256
|
+
# plist never gets pinned to a broken/stale shim.
|
|
257
|
+
|
|
258
|
+
# Probe a candidate: must be executable and respond to --version cleanly.
|
|
259
|
+
_try() {
|
|
260
|
+
[ -n "$1" ] && [ -x "$1" ] || return 1
|
|
261
|
+
"$1" --version >/dev/null 2>&1 || return 1
|
|
262
|
+
return 0
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
# 1. Explicit user override (escape hatch for broken environments).
|
|
266
|
+
if [ -n "\${NEUROLINK_BIN:-}" ]; then
|
|
267
|
+
if _try "$NEUROLINK_BIN"; then
|
|
268
|
+
exec "$NEUROLINK_BIN" "$@"
|
|
269
|
+
fi
|
|
270
|
+
echo "[neurolink-proxy] WARN: NEUROLINK_BIN=$NEUROLINK_BIN is not runnable, trying defaults" >&2
|
|
271
|
+
fi
|
|
272
|
+
|
|
273
|
+
# 2. PATH-based and common install locations. First working one wins.
|
|
274
|
+
for cand in \\
|
|
275
|
+
"$(command -v neurolink 2>/dev/null || true)" \\
|
|
276
|
+
"\${PNPM_HOME:-}/neurolink" \\
|
|
277
|
+
"$HOME/.local/share/pnpm/neurolink" \\
|
|
278
|
+
"$HOME/Library/pnpm/neurolink" \\
|
|
279
|
+
"/usr/local/bin/neurolink" \\
|
|
280
|
+
"/opt/homebrew/bin/neurolink"; do
|
|
281
|
+
if _try "$cand"; then
|
|
282
|
+
exec "$cand" "$@"
|
|
283
|
+
fi
|
|
284
|
+
done
|
|
285
|
+
|
|
286
|
+
# 3. Baked-in fallback: the exact node + script that worked at install time.
|
|
287
|
+
# Always valid at install time; may become stale after package updates
|
|
288
|
+
# (but at that point the PATH candidates above should work).
|
|
289
|
+
BAKED_NODE=${shEscape(bakedNode)}
|
|
290
|
+
BAKED_SCRIPT=${shEscape(bakedScript)}
|
|
291
|
+
if [ -x "$BAKED_NODE" ] && [ -f "$BAKED_SCRIPT" ]; then
|
|
292
|
+
exec "$BAKED_NODE" "$BAKED_SCRIPT" "$@"
|
|
293
|
+
fi
|
|
294
|
+
|
|
295
|
+
echo "[neurolink-proxy] FATAL: no working neurolink binary found." >&2
|
|
296
|
+
echo "[neurolink-proxy] Tried: PATH, \\$PNPM_HOME, \\$HOME/.local/share/pnpm, \\$HOME/Library/pnpm, /usr/local/bin, /opt/homebrew/bin, baked-in install path." >&2
|
|
297
|
+
echo "[neurolink-proxy] Fix: reinstall with 'pnpm add -g @juspay/neurolink' or set NEUROLINK_BIN=/path/to/working/neurolink." >&2
|
|
298
|
+
exit 127
|
|
299
|
+
`;
|
|
300
|
+
writeFileSync(TRAMPOLINE_PATH, script, { mode: 0o755 });
|
|
301
|
+
chmodSync(TRAMPOLINE_PATH, 0o755);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Resolve the `pnpm` binary defensively.
|
|
305
|
+
*
|
|
306
|
+
* Tries multiple candidates in order of preference and validates each by
|
|
307
|
+
* running `--version`. Returns the first one that actually works, along
|
|
308
|
+
* with a list of all candidates tried (for diagnostics). This defends
|
|
309
|
+
* against environments where `which pnpm` returns a broken shim or an
|
|
310
|
+
* incompatible version.
|
|
311
|
+
*
|
|
312
|
+
* Honors `NEUROLINK_PNPM_PATH` as an escape hatch.
|
|
313
|
+
*/
|
|
314
|
+
function resolveFullPnpmPath() {
|
|
315
|
+
const candidates = [];
|
|
316
|
+
// 1. User override
|
|
317
|
+
if (process.env.NEUROLINK_PNPM_PATH) {
|
|
318
|
+
candidates.push(process.env.NEUROLINK_PNPM_PATH);
|
|
319
|
+
}
|
|
320
|
+
// 2. PNPM_HOME (pnpm's own env variable)
|
|
321
|
+
if (process.env.PNPM_HOME) {
|
|
322
|
+
candidates.push(join(process.env.PNPM_HOME, "pnpm"));
|
|
323
|
+
}
|
|
324
|
+
// 3. `which pnpm` — whatever is on PATH
|
|
325
|
+
try {
|
|
326
|
+
const { execFileSync } = _require("node:child_process");
|
|
327
|
+
const whichOut = execFileSync("which", ["pnpm"], {
|
|
328
|
+
encoding: "utf8",
|
|
329
|
+
timeout: 5_000,
|
|
330
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
331
|
+
}).trim();
|
|
332
|
+
if (whichOut) {
|
|
333
|
+
candidates.push(whichOut);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
// ignore
|
|
338
|
+
}
|
|
339
|
+
// 4. Common standalone installer locations
|
|
340
|
+
candidates.push(join(homedir(), ".local", "share", "pnpm", "pnpm"));
|
|
341
|
+
candidates.push(join(homedir(), "Library", "pnpm", "pnpm"));
|
|
342
|
+
// Dedupe while preserving order
|
|
343
|
+
const seen = new Set();
|
|
344
|
+
const unique = candidates.filter((p) => {
|
|
345
|
+
if (!p || seen.has(p)) {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
seen.add(p);
|
|
349
|
+
return true;
|
|
350
|
+
});
|
|
351
|
+
// Probe each candidate
|
|
352
|
+
const tried = unique.map((path) => {
|
|
353
|
+
const version = probeBinVersion(path);
|
|
354
|
+
return { path, version, working: version !== undefined };
|
|
355
|
+
});
|
|
356
|
+
const working = tried.find((r) => r.working);
|
|
357
|
+
if (working) {
|
|
358
|
+
return {
|
|
359
|
+
bin: working.path,
|
|
360
|
+
resolved: true,
|
|
361
|
+
version: working.version,
|
|
362
|
+
tried,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
return { bin: "pnpm", resolved: false, tried };
|
|
366
|
+
}
|
|
204
367
|
function spawnFailOpenGuard(host, port, parentPid) {
|
|
368
|
+
// The guard runs the same version as this process, so process.argv[1]
|
|
369
|
+
// (the currently-running script) is correct here — no stale-path risk.
|
|
205
370
|
const entryScript = process.argv[1];
|
|
206
371
|
if (!entryScript) {
|
|
207
372
|
return undefined;
|
|
208
373
|
}
|
|
374
|
+
const args = [
|
|
375
|
+
entryScript,
|
|
376
|
+
"proxy",
|
|
377
|
+
"guard",
|
|
378
|
+
"--host",
|
|
379
|
+
host,
|
|
380
|
+
"--port",
|
|
381
|
+
String(port),
|
|
382
|
+
"--parent-pid",
|
|
383
|
+
String(parentPid),
|
|
384
|
+
"--quiet",
|
|
385
|
+
];
|
|
386
|
+
// Write guard stdout/stderr to a log file instead of discarding them.
|
|
387
|
+
const { openSync, closeSync, mkdirSync, existsSync } = _require("fs");
|
|
388
|
+
const guardLogDir = join(homedir(), ".neurolink", "logs");
|
|
389
|
+
if (!existsSync(guardLogDir)) {
|
|
390
|
+
mkdirSync(guardLogDir, { recursive: true });
|
|
391
|
+
}
|
|
392
|
+
const guardLogPath = join(guardLogDir, "proxy-guard.log");
|
|
393
|
+
const logFd = openSync(guardLogPath, "a");
|
|
209
394
|
try {
|
|
210
|
-
const args = [
|
|
211
|
-
entryScript,
|
|
212
|
-
"proxy",
|
|
213
|
-
"guard",
|
|
214
|
-
"--host",
|
|
215
|
-
host,
|
|
216
|
-
"--port",
|
|
217
|
-
String(port),
|
|
218
|
-
"--parent-pid",
|
|
219
|
-
String(parentPid),
|
|
220
|
-
"--quiet",
|
|
221
|
-
];
|
|
222
395
|
const child = spawn(process.execPath, args, {
|
|
223
396
|
detached: true,
|
|
224
|
-
stdio: "ignore",
|
|
397
|
+
stdio: ["ignore", logFd, logFd],
|
|
225
398
|
});
|
|
226
399
|
child.unref();
|
|
227
400
|
return child.pid;
|
|
@@ -230,6 +403,9 @@ function spawnFailOpenGuard(host, port, parentPid) {
|
|
|
230
403
|
logger.debug(`[proxy] failed to start fail-open guard: ${error instanceof Error ? error.message : String(error)}`);
|
|
231
404
|
return undefined;
|
|
232
405
|
}
|
|
406
|
+
finally {
|
|
407
|
+
closeSync(logFd); // parent closes its copy; child keeps the fd
|
|
408
|
+
}
|
|
233
409
|
}
|
|
234
410
|
async function runProxyTelemetryManager(command) {
|
|
235
411
|
const { existsSync } = await import("fs");
|
|
@@ -568,10 +744,7 @@ async function createProxyStartApp(params) {
|
|
|
568
744
|
success: account.successCount,
|
|
569
745
|
errors: account.errorCount,
|
|
570
746
|
rateLimits: account.rateLimitCount,
|
|
571
|
-
|
|
572
|
-
cooling: account.coolingUntil
|
|
573
|
-
? account.coolingUntil > Date.now()
|
|
574
|
-
: false,
|
|
747
|
+
cooling: false, // No persistent cooldown — always active
|
|
575
748
|
})),
|
|
576
749
|
},
|
|
577
750
|
config: params.proxyConfig
|
|
@@ -1294,34 +1467,113 @@ export const proxyGuardCommand = {
|
|
|
1294
1467
|
logger.always(`[guard] WARNING: invalid version format "${result.latestVersion}", skipping`);
|
|
1295
1468
|
return;
|
|
1296
1469
|
}
|
|
1297
|
-
|
|
1470
|
+
// Resolve pnpm to a deterministic path, validating that it actually
|
|
1471
|
+
// runs (some environments have broken shims on PATH).
|
|
1472
|
+
const pnpmResolution = resolveFullPnpmPath();
|
|
1473
|
+
// Log the full candidate list so operators can see why a particular
|
|
1474
|
+
// pnpm was chosen (or why none worked).
|
|
1475
|
+
logger.always(`[guard] pnpm candidates: ${pnpmResolution.tried
|
|
1476
|
+
.map((c) => `${c.path}(${c.working ? `v${c.version}` : "BROKEN"})`)
|
|
1477
|
+
.join(", ")}`);
|
|
1478
|
+
if (!pnpmResolution.resolved) {
|
|
1479
|
+
// Environmental problem, not version-specific — skip this cycle
|
|
1480
|
+
// without suppressing the version (so we retry on the next tick
|
|
1481
|
+
// once the user fixes pnpm).
|
|
1482
|
+
logger.always(`[guard] WARNING: no working pnpm found; skipping update cycle. Install pnpm or set NEUROLINK_PNPM_PATH.`);
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
logger.always(`[guard] traffic quiet, installing @juspay/neurolink@${result.latestVersion} via ${pnpmResolution.bin} (pnpm v${pnpmResolution.version})...`);
|
|
1298
1486
|
const { execFileSync } = await import("node:child_process");
|
|
1299
1487
|
try {
|
|
1300
|
-
execFileSync(
|
|
1488
|
+
execFileSync(pnpmResolution.bin, ["add", "-g", `@juspay/neurolink@${result.latestVersion}`], {
|
|
1301
1489
|
timeout: 120_000,
|
|
1302
1490
|
stdio: "pipe",
|
|
1303
1491
|
});
|
|
1304
1492
|
}
|
|
1305
1493
|
catch (installErr) {
|
|
1306
|
-
|
|
1307
|
-
|
|
1494
|
+
// Capture stderr for actionable diagnostics
|
|
1495
|
+
const stderr = installErr &&
|
|
1496
|
+
typeof installErr === "object" &&
|
|
1497
|
+
"stderr" in installErr
|
|
1498
|
+
? String(installErr.stderr).slice(0, 500)
|
|
1499
|
+
: "";
|
|
1500
|
+
const msg = installErr instanceof Error
|
|
1501
|
+
? installErr.message
|
|
1502
|
+
: String(installErr);
|
|
1503
|
+
logger.always(`[guard] WARNING: pnpm install failed: ${msg}${stderr ? `\n stderr: ${stderr}` : ""}`);
|
|
1504
|
+
suppressVersion(result.latestVersion, `install_failed: ${msg.slice(0, 200)}${stderr ? ` | stderr: ${stderr.slice(0, 200)}` : ""}`);
|
|
1308
1505
|
return;
|
|
1309
1506
|
}
|
|
1310
|
-
// 4.
|
|
1507
|
+
// 4. Rewrite the launchd plist so it picks up the (possibly new)
|
|
1508
|
+
// stable bin path, then restart via launchctl.
|
|
1509
|
+
try {
|
|
1510
|
+
const { writeFileSync, existsSync: fsExists, mkdirSync: fsMkdir, } = await import("fs");
|
|
1511
|
+
if (!fsExists(PLIST_DIR)) {
|
|
1512
|
+
fsMkdir(PLIST_DIR, { recursive: true });
|
|
1513
|
+
}
|
|
1514
|
+
// Rewrite the trampoline and plist so the restarted service
|
|
1515
|
+
// resolves the newly installed binary via PATH.
|
|
1516
|
+
writeTrampoline();
|
|
1517
|
+
// Validate the trampoline actually resolves to the NEW version
|
|
1518
|
+
// before asking launchd to restart. If the install somehow left
|
|
1519
|
+
// PATH still pointing at the old version, don't kickstart.
|
|
1520
|
+
const probed = probeBinVersion(TRAMPOLINE_PATH);
|
|
1521
|
+
if (!probed) {
|
|
1522
|
+
logger.always(`[guard] WARNING: trampoline does not resolve to a working neurolink after install; skipping restart.`);
|
|
1523
|
+
suppressVersion(result.latestVersion, `trampoline_broken_after_install: ${TRAMPOLINE_PATH} --version failed`);
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
if (probed !== result.latestVersion) {
|
|
1527
|
+
// The trampoline resolves to a DIFFERENT version than what we
|
|
1528
|
+
// just installed. This means `pnpm add -g` installed into a
|
|
1529
|
+
// store that PATH doesn't reach (store mismatch), or PATH still
|
|
1530
|
+
// shadows with an older shim. Restarting would run the wrong
|
|
1531
|
+
// version — abort.
|
|
1532
|
+
logger.always(`[guard] ABORT: trampoline resolves to v${probed} but installed v${result.latestVersion}.`);
|
|
1533
|
+
logger.always(`[guard] pnpm used: ${pnpmResolution.bin} (v${pnpmResolution.version})`);
|
|
1534
|
+
logger.always(`[guard] This usually means pnpm's global store doesn't match the PATH-visible neurolink.`);
|
|
1535
|
+
logger.always(`[guard] Fix: run 'pnpm add -g @juspay/neurolink' with the SAME pnpm whose bin dir is on PATH.`);
|
|
1536
|
+
suppressVersion(result.latestVersion, `version_mismatch: trampoline=${probed} expected=${result.latestVersion} pnpm=${pnpmResolution.bin}(v${pnpmResolution.version})`);
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
const existingArgs = parseExistingPlistArgs();
|
|
1540
|
+
const updatedPlist = buildPlist(port, host, existingArgs.envFile, existingArgs.configFile);
|
|
1541
|
+
writeFileSync(PLIST_PATH, updatedPlist, "utf-8");
|
|
1542
|
+
logger.always(`[guard] trampoline (resolves to v${probed}) + plist rewritten at ${PLIST_PATH}`);
|
|
1543
|
+
}
|
|
1544
|
+
catch (plistErr) {
|
|
1545
|
+
logger.always(`[guard] WARNING: failed to rewrite plist (restart may use stale path): ${plistErr instanceof Error ? plistErr.message : String(plistErr)}`);
|
|
1546
|
+
// Continue with restart anyway — the stable bin symlink may still be correct
|
|
1547
|
+
}
|
|
1311
1548
|
// Signal the health loop to not exit when it detects
|
|
1312
1549
|
// the parent PID is gone — we're intentionally restarting.
|
|
1313
1550
|
updateRestartInProgress = true;
|
|
1314
|
-
logger.always(`[guard] restarting proxy via launchctl...`);
|
|
1551
|
+
logger.always(`[guard] restarting proxy via launchctl bootout/bootstrap...`);
|
|
1315
1552
|
const uid = process.getuid?.() ?? 501;
|
|
1316
1553
|
try {
|
|
1317
|
-
|
|
1554
|
+
// bootout unloads the in-memory job definition. This is required
|
|
1555
|
+
// because `kickstart -k` reuses the cached plist and ignores any
|
|
1556
|
+
// on-disk changes (like the trampoline rewrite above).
|
|
1557
|
+
try {
|
|
1558
|
+
execFileSync("launchctl", ["bootout", `gui/${uid}/${PLIST_LABEL}`], { timeout: 10_000, stdio: "pipe" });
|
|
1559
|
+
}
|
|
1560
|
+
catch {
|
|
1561
|
+
// Job may not be loaded (first install, or already unloaded)
|
|
1562
|
+
}
|
|
1563
|
+
// bootstrap loads the plist fresh from disk, picking up the
|
|
1564
|
+
// new trampoline-based ProgramArguments.
|
|
1565
|
+
execFileSync("launchctl", ["bootstrap", `gui/${uid}`, PLIST_PATH], {
|
|
1318
1566
|
timeout: 10_000,
|
|
1319
1567
|
stdio: "pipe",
|
|
1320
1568
|
});
|
|
1321
1569
|
}
|
|
1322
|
-
catch {
|
|
1323
|
-
|
|
1324
|
-
|
|
1570
|
+
catch (restartErr) {
|
|
1571
|
+
updateRestartInProgress = false;
|
|
1572
|
+
const msg = restartErr instanceof Error
|
|
1573
|
+
? restartErr.message
|
|
1574
|
+
: String(restartErr);
|
|
1575
|
+
logger.always(`[guard] WARNING: launchctl bootstrap failed: ${msg}`);
|
|
1576
|
+
suppressVersion(result.latestVersion, `restart_failed: ${msg.slice(0, 200)}`);
|
|
1325
1577
|
return;
|
|
1326
1578
|
}
|
|
1327
1579
|
// 5. Wait for healthy restart
|
|
@@ -1584,9 +1836,42 @@ function buildLaunchdPath() {
|
|
|
1584
1836
|
}
|
|
1585
1837
|
return [...segments].join(":");
|
|
1586
1838
|
}
|
|
1839
|
+
/**
|
|
1840
|
+
* Parse the existing launchd plist to extract --env-file and --config values.
|
|
1841
|
+
* Used by the auto-updater to rewrite the plist with the same configuration.
|
|
1842
|
+
*/
|
|
1843
|
+
function parseExistingPlistArgs() {
|
|
1844
|
+
try {
|
|
1845
|
+
const { readFileSync, existsSync: fsExists } = _require("fs");
|
|
1846
|
+
if (!fsExists(PLIST_PATH)) {
|
|
1847
|
+
return {};
|
|
1848
|
+
}
|
|
1849
|
+
const xml = readFileSync(PLIST_PATH, "utf-8");
|
|
1850
|
+
// Extract --env-file value: <string>--env-file</string>\n <string>VALUE</string>
|
|
1851
|
+
const envMatch = xml.match(/<string>--env-file<\/string>\s*<string>([^<]+)<\/string>/);
|
|
1852
|
+
const configMatch = xml.match(/<string>--config<\/string>\s*<string>([^<]+)<\/string>/);
|
|
1853
|
+
// Unescape XML entities so buildPlist() doesn't double-escape them.
|
|
1854
|
+
const unescapeXml = (value) => value
|
|
1855
|
+
?.replace(/'/g, "'")
|
|
1856
|
+
.replace(/"/g, '"')
|
|
1857
|
+
.replace(/>/g, ">")
|
|
1858
|
+
.replace(/</g, "<")
|
|
1859
|
+
.replace(/&/g, "&");
|
|
1860
|
+
return {
|
|
1861
|
+
envFile: unescapeXml(envMatch?.[1]),
|
|
1862
|
+
configFile: unescapeXml(configMatch?.[1]),
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
catch {
|
|
1866
|
+
return {};
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1587
1869
|
function buildPlist(port, host, envFile, configFile) {
|
|
1588
|
-
|
|
1589
|
-
|
|
1870
|
+
// The plist invokes the trampoline script (a tiny shell wrapper at
|
|
1871
|
+
// ~/.neurolink/bin/neurolink-proxy) which re-resolves the real
|
|
1872
|
+
// `neurolink` binary via PATH on every launch. This way, launchd
|
|
1873
|
+
// is never pinned to a version-specific pnpm store path.
|
|
1874
|
+
const trampolinePath = escapeXml(TRAMPOLINE_PATH);
|
|
1590
1875
|
const envFileArgs = envFile
|
|
1591
1876
|
? `
|
|
1592
1877
|
<string>--env-file</string>
|
|
@@ -1607,8 +1892,7 @@ function buildPlist(port, host, envFile, configFile) {
|
|
|
1607
1892
|
|
|
1608
1893
|
<key>ProgramArguments</key>
|
|
1609
1894
|
<array>
|
|
1610
|
-
<string>${
|
|
1611
|
-
<string>${entryScript}</string>
|
|
1895
|
+
<string>${trampolinePath}</string>
|
|
1612
1896
|
<string>proxy</string>
|
|
1613
1897
|
<string>start</string>
|
|
1614
1898
|
<string>--port</string>
|
|
@@ -1709,6 +1993,24 @@ export const proxyInstallCommand = {
|
|
|
1709
1993
|
if (!existsSync(PLIST_DIR)) {
|
|
1710
1994
|
mkdirSync(PLIST_DIR, { recursive: true });
|
|
1711
1995
|
}
|
|
1996
|
+
writeTrampoline();
|
|
1997
|
+
console.info(chalk.green(`✓ Trampoline written to ${TRAMPOLINE_PATH}`));
|
|
1998
|
+
// Sanity-check: run the trampoline itself and confirm it resolves to
|
|
1999
|
+
// a working neurolink binary. This catches environments where every
|
|
2000
|
+
// PATH-based candidate is broken AND the baked-in path is unreachable.
|
|
2001
|
+
const trampolineVersion = probeBinVersion(TRAMPOLINE_PATH);
|
|
2002
|
+
if (!trampolineVersion) {
|
|
2003
|
+
console.info(chalk.red(`✗ Trampoline validation failed: ${TRAMPOLINE_PATH} --version did not run cleanly.`));
|
|
2004
|
+
console.info(chalk.yellow(` The launchd service would not be able to start neurolink. Fix your install first.`));
|
|
2005
|
+
console.info(chalk.yellow(` Try: 'pnpm add -g @juspay/neurolink' or set NEUROLINK_BIN=/path/to/working/neurolink.`));
|
|
2006
|
+
process.exit(1);
|
|
2007
|
+
}
|
|
2008
|
+
if (trampolineVersion !== PROXY_VERSION) {
|
|
2009
|
+
console.info(chalk.red(`✗ Trampoline resolves to v${trampolineVersion} but this installer is v${PROXY_VERSION}.`));
|
|
2010
|
+
console.info(chalk.yellow(` PATH may shadow this installation with an older version. Fix your PATH or set NEUROLINK_BIN.`));
|
|
2011
|
+
process.exit(1);
|
|
2012
|
+
}
|
|
2013
|
+
console.info(chalk.green(`✓ Trampoline validated (resolves to neurolink v${trampolineVersion})`));
|
|
1712
2014
|
const plist = buildPlist(port, host, envFile, configFile);
|
|
1713
2015
|
writeFileSync(PLIST_PATH, plist, "utf-8");
|
|
1714
2016
|
console.info(chalk.green(`✓ Plist written to ${PLIST_PATH}`));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ClaudeProxyModelTier,
|
|
1
|
+
import type { ClaudeProxyModelTier, FallbackEntry, ParsedClaudeRequest, ProxyTranslationAttempt, ProxyTranslationPlan } from "../types/index.js";
|
|
2
2
|
export type { ClaudeProxyModelTier, ProxyTranslationAttempt, ProxyTranslationPlan, };
|
|
3
3
|
export declare function inferClaudeProxyModelTier(modelName: string): ClaudeProxyModelTier;
|
|
4
4
|
/**
|
|
@@ -12,32 +12,7 @@ export declare function buildProxyTranslationPlan(primary: {
|
|
|
12
12
|
model?: string;
|
|
13
13
|
}, fallbackChain: FallbackEntry[], requestedModel: string, _parsed: ParsedClaudeRequest): ProxyTranslationPlan;
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
* Returns
|
|
15
|
+
* Parse the retry-after header from an upstream 429 response.
|
|
16
|
+
* Returns milliseconds to wait, or 0 if no valid header present.
|
|
17
17
|
*/
|
|
18
|
-
export declare function
|
|
19
|
-
/**
|
|
20
|
-
* Partition accounts into eligible (no cooldown) and skipped (cooling down).
|
|
21
|
-
*/
|
|
22
|
-
export declare function partitionAccountsByCooldown<T extends {
|
|
23
|
-
key: string;
|
|
24
|
-
}>(accounts: T[], getState: (account: T) => RuntimeAccountState, now?: number): {
|
|
25
|
-
eligible: T[];
|
|
26
|
-
skipped: CooldownSkippedAccount<T>[];
|
|
27
|
-
};
|
|
28
|
-
/**
|
|
29
|
-
* Apply a rate-limit cooldown to an account.
|
|
30
|
-
* Uses simple exponential backoff with a floor and cap.
|
|
31
|
-
*/
|
|
32
|
-
export declare function applyRateLimitCooldown(args: {
|
|
33
|
-
state: RuntimeAccountState;
|
|
34
|
-
retryAfterMs?: number;
|
|
35
|
-
now?: number;
|
|
36
|
-
capMs: number;
|
|
37
|
-
}): {
|
|
38
|
-
backoffMs: number;
|
|
39
|
-
};
|
|
40
|
-
/**
|
|
41
|
-
* Clear cooldown state for an account after a successful request.
|
|
42
|
-
*/
|
|
43
|
-
export declare function clearAccountCooldown(state: RuntimeAccountState): void;
|
|
18
|
+
export declare function parseRetryAfterMs(retryAfterHeader: string | null): number;
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
const DEFAULT_COOLDOWN_FLOOR_MS = 1_000;
|
|
2
1
|
export function inferClaudeProxyModelTier(modelName) {
|
|
3
2
|
const normalized = modelName.toLowerCase();
|
|
4
3
|
if (normalized.includes("opus")) {
|
|
@@ -50,55 +49,24 @@ export function buildProxyTranslationPlan(primary, fallbackChain, requestedModel
|
|
|
50
49
|
};
|
|
51
50
|
}
|
|
52
51
|
// ---------------------------------------------------------------------------
|
|
53
|
-
//
|
|
52
|
+
// Retry-after parsing helper
|
|
54
53
|
// ---------------------------------------------------------------------------
|
|
55
54
|
/**
|
|
56
|
-
*
|
|
57
|
-
* Returns
|
|
55
|
+
* Parse the retry-after header from an upstream 429 response.
|
|
56
|
+
* Returns milliseconds to wait, or 0 if no valid header present.
|
|
58
57
|
*/
|
|
59
|
-
export function
|
|
60
|
-
if (
|
|
61
|
-
return
|
|
58
|
+
export function parseRetryAfterMs(retryAfterHeader) {
|
|
59
|
+
if (!retryAfterHeader) {
|
|
60
|
+
return 0;
|
|
62
61
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
* Partition accounts into eligible (no cooldown) and skipped (cooling down).
|
|
67
|
-
*/
|
|
68
|
-
export function partitionAccountsByCooldown(accounts, getState, now = Date.now()) {
|
|
69
|
-
const eligible = [];
|
|
70
|
-
const skipped = [];
|
|
71
|
-
for (const account of accounts) {
|
|
72
|
-
const state = getState(account);
|
|
73
|
-
const until = getAccountCooldownUntil(state, now);
|
|
74
|
-
if (until !== null) {
|
|
75
|
-
skipped.push({
|
|
76
|
-
account,
|
|
77
|
-
cooldown: { until, backoffLevel: state.backoffLevel },
|
|
78
|
-
});
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
eligible.push(account);
|
|
62
|
+
const seconds = parseInt(retryAfterHeader, 10);
|
|
63
|
+
if (!Number.isNaN(seconds)) {
|
|
64
|
+
return Math.max(1, seconds) * 1000;
|
|
82
65
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
*/
|
|
89
|
-
export function applyRateLimitCooldown(args) {
|
|
90
|
-
const now = args.now ?? Date.now();
|
|
91
|
-
const baseCooldownMs = Math.max(args.retryAfterMs ?? 0, DEFAULT_COOLDOWN_FLOOR_MS);
|
|
92
|
-
const backoffMs = Math.min(baseCooldownMs * 2 ** args.state.backoffLevel, args.capMs);
|
|
93
|
-
args.state.coolingUntil = now + backoffMs;
|
|
94
|
-
args.state.backoffLevel += 1;
|
|
95
|
-
return { backoffMs };
|
|
96
|
-
}
|
|
97
|
-
/**
|
|
98
|
-
* Clear cooldown state for an account after a successful request.
|
|
99
|
-
*/
|
|
100
|
-
export function clearAccountCooldown(state) {
|
|
101
|
-
state.coolingUntil = undefined;
|
|
102
|
-
state.backoffLevel = 0;
|
|
66
|
+
const date = new Date(retryAfterHeader);
|
|
67
|
+
if (!Number.isNaN(date.getTime())) {
|
|
68
|
+
return Math.max(1000, date.getTime() - Date.now());
|
|
69
|
+
}
|
|
70
|
+
return 0;
|
|
103
71
|
}
|
|
104
72
|
//# sourceMappingURL=routingPolicy.js.map
|
|
@@ -8,7 +8,6 @@ export declare function recordAttempt(accountLabel: string, accountType: string)
|
|
|
8
8
|
export declare function recordFinalSuccess(accountLabel?: string, accountType?: string): void;
|
|
9
9
|
export declare function recordAttemptError(accountLabel: string, accountType: string, status: number): void;
|
|
10
10
|
export declare function recordFinalError(_status: number, accountLabel?: string, accountType?: string): void;
|
|
11
|
-
export declare function recordCooldown(accountLabel: string, accountType: string, cooldownUntil: number, backoffLevel: number): void;
|
|
12
11
|
export declare function getStats(): ProxyStats;
|
|
13
12
|
export declare function getAccountStats(label: string): AccountStats | undefined;
|
|
14
13
|
export declare function resetStats(): void;
|
|
@@ -24,7 +24,6 @@ export function recordFinalSuccess(accountLabel, accountType) {
|
|
|
24
24
|
if (accountLabel && accountType) {
|
|
25
25
|
const acct = ensureAccount(accountLabel, accountType);
|
|
26
26
|
acct.successCount++;
|
|
27
|
-
acct.currentBackoffLevel = 0;
|
|
28
27
|
}
|
|
29
28
|
}
|
|
30
29
|
export function recordAttemptError(accountLabel, accountType, status) {
|
|
@@ -45,11 +44,6 @@ export function recordFinalError(_status, accountLabel, accountType) {
|
|
|
45
44
|
acct.lastErrorAt = Date.now();
|
|
46
45
|
}
|
|
47
46
|
}
|
|
48
|
-
export function recordCooldown(accountLabel, accountType, cooldownUntil, backoffLevel) {
|
|
49
|
-
const acct = ensureAccount(accountLabel, accountType);
|
|
50
|
-
acct.coolingUntil = cooldownUntil;
|
|
51
|
-
acct.currentBackoffLevel = backoffLevel;
|
|
52
|
-
}
|
|
53
47
|
export function getStats() {
|
|
54
48
|
const accounts = {};
|
|
55
49
|
for (const [label, account] of Object.entries(stats.accounts)) {
|
|
@@ -80,7 +74,6 @@ function ensureAccount(label, type) {
|
|
|
80
74
|
errorCount: 0,
|
|
81
75
|
rateLimitCount: 0,
|
|
82
76
|
lastAttemptAt: 0,
|
|
83
|
-
currentBackoffLevel: 0,
|
|
84
77
|
};
|
|
85
78
|
}
|
|
86
79
|
return stats.accounts[label];
|