@juspay/neurolink 11.29.2 → 12.0.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 +54 -2
- package/dist/auth/anthropicOAuth.d.ts +50 -0
- package/dist/auth/anthropicOAuth.js +78 -0
- package/dist/browser/neurolink.min.js +393 -393
- package/dist/cli/commands/proxy.d.ts +2 -0
- package/dist/cli/commands/proxy.js +284 -4
- package/dist/cli/commands/proxyExpose.d.ts +35 -0
- package/dist/cli/commands/proxyExpose.js +252 -0
- package/dist/cli/commands/proxyPeer.d.ts +29 -0
- package/dist/cli/commands/proxyPeer.js +738 -0
- package/dist/cli/commands/proxyShare.d.ts +37 -0
- package/dist/cli/commands/proxyShare.js +1080 -0
- package/dist/cli/parser.js +7 -1
- package/dist/core/baseProvider.js +20 -1
- package/dist/neurolink.js +30 -2
- package/dist/proxy/peerStore.d.ts +52 -0
- package/dist/proxy/peerStore.js +324 -0
- package/dist/proxy/peerTransport.d.ts +38 -0
- package/dist/proxy/peerTransport.js +242 -0
- package/dist/proxy/proxyPaths.d.ts +8 -0
- package/dist/proxy/proxyPaths.js +55 -17
- package/dist/proxy/requestLogger.js +8 -0
- package/dist/proxy/residentGrants.d.ts +57 -0
- package/dist/proxy/residentGrants.js +393 -0
- package/dist/proxy/shareAudit.d.ts +81 -0
- package/dist/proxy/shareAudit.js +280 -0
- package/dist/proxy/shareContext.d.ts +38 -0
- package/dist/proxy/shareContext.js +92 -0
- package/dist/proxy/shareGate.d.ts +64 -0
- package/dist/proxy/shareGate.js +216 -0
- package/dist/proxy/shareGrants.d.ts +115 -0
- package/dist/proxy/shareGrants.js +590 -0
- package/dist/proxy/shareLease.d.ts +101 -0
- package/dist/proxy/shareLease.js +192 -0
- package/dist/proxy/shareLedger.d.ts +105 -0
- package/dist/proxy/shareLedger.js +406 -0
- package/dist/proxy/shareListener.d.ts +60 -0
- package/dist/proxy/shareListener.js +143 -0
- package/dist/proxy/shareNotes.d.ts +97 -0
- package/dist/proxy/shareNotes.js +234 -0
- package/dist/proxy/sharePolicy.d.ts +110 -0
- package/dist/proxy/sharePolicy.js +366 -0
- package/dist/proxy/shareProvisioning.d.ts +110 -0
- package/dist/proxy/shareProvisioning.js +237 -0
- package/dist/proxy/shareReceipts.d.ts +99 -0
- package/dist/proxy/shareReceipts.js +303 -0
- package/dist/proxy/shareSigning.d.ts +40 -0
- package/dist/proxy/shareSigning.js +78 -0
- package/dist/server/routes/claudeProxyRoutes.js +1066 -3
- package/dist/types/cli.d.ts +61 -0
- package/dist/types/proxy.d.ts +781 -0
- package/dist/utils/streamCancellation.d.ts +46 -0
- package/dist/utils/streamCancellation.js +90 -0
- package/dist/utils/ttsStream.js +34 -1
- package/dist/voice/livekit/realtimeEventBridge.js +27 -6
- package/package.json +2 -1
package/dist/cli/parser.js
CHANGED
|
@@ -23,6 +23,9 @@ import { ObservabilityCommandFactory } from "./commands/observability.js";
|
|
|
23
23
|
import { TelemetryCommandFactory } from "./commands/telemetry.js";
|
|
24
24
|
import { proxyStartCommand, proxyStatusCommand, proxyTelemetryCommand, proxySetupCommand, proxyGuardCommand, proxyInstallCommand, proxyUninstallCommand, } from "./commands/proxy.js";
|
|
25
25
|
import { proxyAnalyzeCommand } from "./commands/proxyAnalyze.js";
|
|
26
|
+
import { proxyShareCommand } from "./commands/proxyShare.js";
|
|
27
|
+
import { proxyPeerCommand } from "./commands/proxyPeer.js";
|
|
28
|
+
import { proxyExposeCommand } from "./commands/proxyExpose.js";
|
|
26
29
|
import { proxyReplayCommand } from "./commands/proxyReplay.js";
|
|
27
30
|
import { EvaluateCommandFactory } from "./commands/evaluate.js";
|
|
28
31
|
import { TaskCommandFactory } from "./commands/task.js";
|
|
@@ -215,6 +218,9 @@ export function initializeCliParser() {
|
|
|
215
218
|
builder: (yargs) => yargs
|
|
216
219
|
.command(proxyStartCommand)
|
|
217
220
|
.command(proxyStatusCommand)
|
|
221
|
+
.command(proxyShareCommand)
|
|
222
|
+
.command(proxyPeerCommand)
|
|
223
|
+
.command(proxyExposeCommand)
|
|
218
224
|
.command(proxyAnalyzeCommand)
|
|
219
225
|
.command(proxyReplayCommand)
|
|
220
226
|
.command(proxyTelemetryCommand)
|
|
@@ -222,7 +228,7 @@ export function initializeCliParser() {
|
|
|
222
228
|
.command(proxyGuardCommand)
|
|
223
229
|
.command(proxyInstallCommand)
|
|
224
230
|
.command(proxyUninstallCommand)
|
|
225
|
-
.demandCommand(1, "Please specify a proxy subcommand: start, status, analyze, replay <export|compare>, telemetry <setup|start|stop|status|logs|import-dashboard>, setup, guard, install, or uninstall"),
|
|
231
|
+
.demandCommand(1, "Please specify a proxy subcommand: start, status, share <create|provision|url|list|status|pause|resume|revoke|topup|set|link|rotate|level|note|notes|receipts|delete>, peer <add|request|sync|receipts|net|redeem|list|status|test|remove|pause|resume|set>, expose, analyze, replay <export|compare>, telemetry <setup|start|stop|status|logs|import-dashboard>, setup, guard, install, or uninstall"),
|
|
226
232
|
handler: () => { },
|
|
227
233
|
})
|
|
228
234
|
// Evaluate Command Group - Using EvaluateCommandFactory
|
|
@@ -15,6 +15,7 @@ import { hasLifecycleErrorFired, markLifecycleErrorFired, } from "../utils/lifec
|
|
|
15
15
|
import { resolveLifecycleTimeoutMs } from "../utils/lifecycleTimeout.js";
|
|
16
16
|
import { logger } from "../utils/logger.js";
|
|
17
17
|
import { interleaveTTSStream } from "../utils/ttsStream.js";
|
|
18
|
+
import { attachStreamCancel, cancelStream, releaseIterator, } from "../utils/streamCancellation.js";
|
|
18
19
|
import { TimeoutError as AsyncTimeoutError, withTimeoutFn, } from "../utils/async/withTimeout.js";
|
|
19
20
|
import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../utils/timeout.js";
|
|
20
21
|
import { shouldDisableBuiltinTools } from "../utils/toolUtils.js";
|
|
@@ -383,11 +384,20 @@ export class BaseProvider {
|
|
|
383
384
|
// Arrow, like `safeFire` above: the generator is a plain function expression, so
|
|
384
385
|
// `this` is not bound inside it.
|
|
385
386
|
const classifyStreamError = (e) => this.classifyStreamError(e);
|
|
387
|
+
// Hold the upstream iterator rather than letting `for await` create one
|
|
388
|
+
// internally, so the cancel hook below can close it directly. Iterating
|
|
389
|
+
// `upstreamIterable` is equivalent to iterating `originalStream` — same
|
|
390
|
+
// iterator, same early-exit `return()` semantics — it just leaves a handle
|
|
391
|
+
// reachable from outside the generator.
|
|
392
|
+
const upstreamIterator = originalStream[Symbol.asyncIterator]();
|
|
393
|
+
const upstreamIterable = {
|
|
394
|
+
[Symbol.asyncIterator]: () => upstreamIterator,
|
|
395
|
+
};
|
|
386
396
|
const wrappedStream = (async function* () {
|
|
387
397
|
let accumulated = "";
|
|
388
398
|
let seq = 0;
|
|
389
399
|
try {
|
|
390
|
-
for await (const chunk of
|
|
400
|
+
for await (const chunk of upstreamIterable) {
|
|
391
401
|
const textPart = chunk &&
|
|
392
402
|
typeof chunk === "object" &&
|
|
393
403
|
"content" in chunk &&
|
|
@@ -434,6 +444,15 @@ export class BaseProvider {
|
|
|
434
444
|
throw classifyStreamError(err);
|
|
435
445
|
}
|
|
436
446
|
})();
|
|
447
|
+
// A consumer that breaks out of the stream cannot reach this generator
|
|
448
|
+
// through `.return()` while it is parked awaiting the provider — that
|
|
449
|
+
// request queues behind the in-flight `next()`. The hook closes the
|
|
450
|
+
// upstream directly and forwards the request to any wrapper below, so
|
|
451
|
+
// abandoning a stream really does release the provider connection.
|
|
452
|
+
attachStreamCancel(wrappedStream, () => {
|
|
453
|
+
cancelStream(originalStream);
|
|
454
|
+
releaseIterator(upstreamIterator);
|
|
455
|
+
});
|
|
437
456
|
return { ...result, stream: wrappedStream };
|
|
438
457
|
}
|
|
439
458
|
/**
|
package/dist/neurolink.js
CHANGED
|
@@ -1229,9 +1229,23 @@ export class NeuroLink {
|
|
|
1229
1229
|
});
|
|
1230
1230
|
});
|
|
1231
1231
|
// Fire-and-forget: registrations complete before any generate/stream call
|
|
1232
|
-
// because those calls await initializeMCP() which is slower
|
|
1232
|
+
// because those calls await initializeMCP() which is slower.
|
|
1233
|
+
//
|
|
1234
|
+
// The rejection handler is not decoration. registerTool() throws on a
|
|
1235
|
+
// failed name/description validation (mcp/toolRegistry.ts), and that throw
|
|
1236
|
+
// is not inside a try — so a rejected registration on a `void`-detached
|
|
1237
|
+
// one-argument `.then()` would be an unhandled rejection, which terminates
|
|
1238
|
+
// the process. Today the tool names come from createFileTools(), which are
|
|
1239
|
+
// internal constants that pass validation, so this is latent rather than
|
|
1240
|
+
// live; it stops being latent the moment a name is derived from anything
|
|
1241
|
+
// outside this file. Losing one tool registration is the intended failure
|
|
1242
|
+
// mode here — losing the host process is not.
|
|
1233
1243
|
void Promise.all(registrations).then(() => {
|
|
1234
1244
|
logger.debug(`[NeuroLink] Registered ${Object.keys(fileTools).length} file reference tools`);
|
|
1245
|
+
}, (error) => {
|
|
1246
|
+
logger.warn("[NeuroLink] File tool registration failed", {
|
|
1247
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1248
|
+
});
|
|
1235
1249
|
});
|
|
1236
1250
|
}
|
|
1237
1251
|
/**
|
|
@@ -1253,7 +1267,15 @@ export class NeuroLink {
|
|
|
1253
1267
|
// registerTool is async but its core logic is synchronous (Map.set).
|
|
1254
1268
|
// We fire-and-forget here but tools are available immediately after
|
|
1255
1269
|
// the synchronous validation + map insertion completes.
|
|
1256
|
-
|
|
1270
|
+
//
|
|
1271
|
+
// The .catch() at the end of this call is required for the same reason
|
|
1272
|
+
// as in registerFileTools() above: registerTool() throws on failed
|
|
1273
|
+
// validation, and an unhandled rejection off a `void`-detached call
|
|
1274
|
+
// terminates the process rather than failing this one registration.
|
|
1275
|
+
// Latent today (createTaskTools() supplies internal, valid names), and
|
|
1276
|
+
// cheap to keep correct.
|
|
1277
|
+
void this.toolRegistry
|
|
1278
|
+
.registerTool(toolId, toolInfo, {
|
|
1257
1279
|
execute: async (params) => {
|
|
1258
1280
|
try {
|
|
1259
1281
|
const result = await toolDef.execute(params, {
|
|
@@ -1276,6 +1298,12 @@ export class NeuroLink {
|
|
|
1276
1298
|
},
|
|
1277
1299
|
description: toolDef.description,
|
|
1278
1300
|
inputSchema: {},
|
|
1301
|
+
})
|
|
1302
|
+
.catch((error) => {
|
|
1303
|
+
logger.warn("[NeuroLink] Task tool registration failed", {
|
|
1304
|
+
toolId,
|
|
1305
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1306
|
+
});
|
|
1279
1307
|
});
|
|
1280
1308
|
}
|
|
1281
1309
|
logger.debug(`[NeuroLink] Registered ${Object.keys(taskTools).length} task tools`);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peers this node may borrow capacity from.
|
|
3
|
+
*
|
|
4
|
+
* The borrower's half of peer sharing. Each entry is a lender's exposed proxy
|
|
5
|
+
* plus the share token they issued; the lender remains the authority on what
|
|
6
|
+
* that token may do, so nothing here tries to second-guess a grant's policy.
|
|
7
|
+
*
|
|
8
|
+
* Peer cooldowns are kept here rather than in `accountCooldown.ts` on purpose.
|
|
9
|
+
* A peer is not an account: its unavailability reasons are grant-shaped
|
|
10
|
+
* ("paused", "exhausted") rather than window-shaped, and the account cooldown
|
|
11
|
+
* store's Anthropic quota keying is documented as label-based, which a peer key
|
|
12
|
+
* has no business participating in.
|
|
13
|
+
*
|
|
14
|
+
* @module proxy/peerStore
|
|
15
|
+
*/
|
|
16
|
+
import type { ProxyPeer, ProxyPeerCooldownReason, ProxyPeerInput, ProxyPeerObservation, ProxyPeerPendingProvision } from "../types/index.js";
|
|
17
|
+
export declare function initPeerStore(peersFilePath: string): void;
|
|
18
|
+
export declare function listPeers(): Promise<ProxyPeer[]>;
|
|
19
|
+
export declare function getPeer(name: string): Promise<ProxyPeer | undefined>;
|
|
20
|
+
/**
|
|
21
|
+
* Peers worth trying right now, in the order they should be tried.
|
|
22
|
+
*
|
|
23
|
+
* Disabled and cooling peers are dropped rather than sorted last: a borrowed
|
|
24
|
+
* request is already the fallback path, and spending its latency budget on a
|
|
25
|
+
* peer that just said "paused" helps nobody.
|
|
26
|
+
*/
|
|
27
|
+
export declare function selectBorrowablePeers(now?: number): Promise<ProxyPeer[]>;
|
|
28
|
+
export declare function addPeer(input: ProxyPeerInput): Promise<ProxyPeer>;
|
|
29
|
+
export declare function removePeer(name: string): Promise<boolean>;
|
|
30
|
+
export declare function setPeerEnabled(name: string, enabled: boolean): Promise<ProxyPeer | undefined>;
|
|
31
|
+
export declare function updatePeer(name: string, patch: {
|
|
32
|
+
priority?: number;
|
|
33
|
+
note?: string;
|
|
34
|
+
url?: string;
|
|
35
|
+
token?: string;
|
|
36
|
+
receiptSecret?: string;
|
|
37
|
+
reciprocalPeer?: string;
|
|
38
|
+
lastReceiptSequence?: number;
|
|
39
|
+
/** `null` clears an outstanding provisioning request. */
|
|
40
|
+
pendingProvision?: ProxyPeerPendingProvision | null;
|
|
41
|
+
}): Promise<ProxyPeer | undefined>;
|
|
42
|
+
/** How long a peer should be left alone after this kind of refusal. */
|
|
43
|
+
export declare function peerCooldownMs(reason: ProxyPeerCooldownReason): number;
|
|
44
|
+
/**
|
|
45
|
+
* Park a peer after a refusal.
|
|
46
|
+
*
|
|
47
|
+
* `retryAfterSeconds` from the lender wins when it is longer than our default —
|
|
48
|
+
* the lender knows when its window turns over and we do not — up to a week.
|
|
49
|
+
*/
|
|
50
|
+
export declare function coolPeer(name: string, reason: ProxyPeerCooldownReason, retryAfterSeconds?: number): Promise<void>;
|
|
51
|
+
/** Clear a cooldown after a peer serves successfully. */
|
|
52
|
+
export declare function recordPeerSuccess(name: string, observation?: ProxyPeerObservation): Promise<void>;
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peers this node may borrow capacity from.
|
|
3
|
+
*
|
|
4
|
+
* The borrower's half of peer sharing. Each entry is a lender's exposed proxy
|
|
5
|
+
* plus the share token they issued; the lender remains the authority on what
|
|
6
|
+
* that token may do, so nothing here tries to second-guess a grant's policy.
|
|
7
|
+
*
|
|
8
|
+
* Peer cooldowns are kept here rather than in `accountCooldown.ts` on purpose.
|
|
9
|
+
* A peer is not an account: its unavailability reasons are grant-shaped
|
|
10
|
+
* ("paused", "exhausted") rather than window-shaped, and the account cooldown
|
|
11
|
+
* store's Anthropic quota keying is documented as label-based, which a peer key
|
|
12
|
+
* has no business participating in.
|
|
13
|
+
*
|
|
14
|
+
* @module proxy/peerStore
|
|
15
|
+
*/
|
|
16
|
+
import { readFile, stat } from "node:fs/promises";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { AsyncMutex } from "../utils/asyncMutex.js";
|
|
20
|
+
import { logger } from "../utils/logger.js";
|
|
21
|
+
import { writeJsonSnapshotAtomically } from "./snapshotPersistence.js";
|
|
22
|
+
const PEERS_FILE = "proxy-peers.json";
|
|
23
|
+
const RELOAD_TTL_MS = 1_000;
|
|
24
|
+
/**
|
|
25
|
+
* How long a peer is left alone after each refusal kind.
|
|
26
|
+
*
|
|
27
|
+
* "Exhausted" and "withheld" are the lender's capacity talking, and that
|
|
28
|
+
* recovers on a window boundary rather than in seconds — retrying sooner just
|
|
29
|
+
* burns latency on every request. "Paused" is a human decision, so the wait is
|
|
30
|
+
* long but not punitive. Transport trouble gets the shortest wait, because it
|
|
31
|
+
* is the one most likely to clear on its own.
|
|
32
|
+
*/
|
|
33
|
+
const COOLDOWN_MS_BY_REASON = {
|
|
34
|
+
exhausted: 900_000,
|
|
35
|
+
withheld: 600_000,
|
|
36
|
+
paused: 300_000,
|
|
37
|
+
revoked: 86_400_000,
|
|
38
|
+
expired: 86_400_000,
|
|
39
|
+
unreachable: 60_000,
|
|
40
|
+
upstream_error: 120_000,
|
|
41
|
+
};
|
|
42
|
+
let customPeersFilePath = null;
|
|
43
|
+
let cache = {};
|
|
44
|
+
let cacheLoadedAt = 0;
|
|
45
|
+
let cacheMtimeMs = -1;
|
|
46
|
+
let cacheValid = false;
|
|
47
|
+
const mutationMutex = new AsyncMutex();
|
|
48
|
+
export function initPeerStore(peersFilePath) {
|
|
49
|
+
customPeersFilePath = peersFilePath;
|
|
50
|
+
cache = {};
|
|
51
|
+
cacheLoadedAt = 0;
|
|
52
|
+
cacheMtimeMs = -1;
|
|
53
|
+
cacheValid = false;
|
|
54
|
+
}
|
|
55
|
+
function getPeersFilePath() {
|
|
56
|
+
return customPeersFilePath ?? join(homedir(), ".neurolink", PEERS_FILE);
|
|
57
|
+
}
|
|
58
|
+
function isPeer(value) {
|
|
59
|
+
if (!value || typeof value !== "object") {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const candidate = value;
|
|
63
|
+
return (typeof candidate.name === "string" &&
|
|
64
|
+
typeof candidate.url === "string" &&
|
|
65
|
+
typeof candidate.token === "string" &&
|
|
66
|
+
// Both are load-bearing on the read path and `addPeer` always writes them:
|
|
67
|
+
// `listPeers` sorts on `priority`, and a non-boolean `enabled` would drop
|
|
68
|
+
// the peer from `selectBorrowablePeers` with no explanation.
|
|
69
|
+
typeof candidate.priority === "number" &&
|
|
70
|
+
typeof candidate.enabled === "boolean");
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Is this error simply "the file is not there yet"?
|
|
74
|
+
*
|
|
75
|
+
* The distinction is load-bearing. An absent file genuinely is an empty map —
|
|
76
|
+
* nothing has been written yet. Every *other* `stat`/read failure (`EACCES`,
|
|
77
|
+
* `EIO`, `EMFILE`, a full descriptor table) is a failure to observe the file,
|
|
78
|
+
* and answering one with an empty map is how a whole store gets erased: a
|
|
79
|
+
* caller passing `force` is about to `persist()` the map back over the real
|
|
80
|
+
* contents it just failed to read.
|
|
81
|
+
*/
|
|
82
|
+
function isMissingFileError(error) {
|
|
83
|
+
return error?.code === "ENOENT";
|
|
84
|
+
}
|
|
85
|
+
async function ensureLoaded(options = {}) {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
if (!options.force && cacheValid && now - cacheLoadedAt < RELOAD_TTL_MS) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const path = getPeersFilePath();
|
|
91
|
+
let mtimeMs;
|
|
92
|
+
try {
|
|
93
|
+
mtimeMs = (await stat(path)).mtimeMs;
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (!isMissingFileError(error)) {
|
|
97
|
+
// Not "no file" but "could not look" — see `isMissingFileError`. Let it
|
|
98
|
+
// out: a mutation must abort rather than persist an empty map over a
|
|
99
|
+
// store it never managed to read.
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
cache = {};
|
|
103
|
+
cacheMtimeMs = -1;
|
|
104
|
+
cacheLoadedAt = now;
|
|
105
|
+
cacheValid = true;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// A forced load skips this. mtime is the fast path for a read, not a
|
|
109
|
+
// correctness check for a write: several filesystems stamp it at one-second
|
|
110
|
+
// granularity, so a write landing in the same second as our last read is
|
|
111
|
+
// indistinguishable from no write at all — and every caller passing `force`
|
|
112
|
+
// is about to persist the whole map back over whatever it missed.
|
|
113
|
+
if (!options.force && cacheValid && mtimeMs === cacheMtimeMs) {
|
|
114
|
+
cacheLoadedAt = now;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
119
|
+
cache = Object.fromEntries(Object.entries(parsed?.peers ?? {}).filter((entry) => isPeer(entry[1])));
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (options.force) {
|
|
123
|
+
// A mutation is about to write the whole map back. Treating a corrupt
|
|
124
|
+
// file as empty here would make that write the thing that finishes the
|
|
125
|
+
// corruption off, so the mutation aborts and the file survives for a
|
|
126
|
+
// human to look at. Read paths below keep the tolerant behaviour.
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
logger.always(`[proxy] peer list unreadable, treating as empty: ${error instanceof Error ? error.message : String(error)}`);
|
|
130
|
+
cache = {};
|
|
131
|
+
}
|
|
132
|
+
cacheMtimeMs = mtimeMs;
|
|
133
|
+
cacheLoadedAt = now;
|
|
134
|
+
cacheValid = true;
|
|
135
|
+
}
|
|
136
|
+
async function persist() {
|
|
137
|
+
const file = { schemaVersion: 1, peers: cache };
|
|
138
|
+
await writeJsonSnapshotAtomically(getPeersFilePath(), file);
|
|
139
|
+
try {
|
|
140
|
+
cacheMtimeMs = (await stat(getPeersFilePath())).mtimeMs;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
cacheMtimeMs = -1;
|
|
144
|
+
}
|
|
145
|
+
cacheLoadedAt = Date.now();
|
|
146
|
+
cacheValid = true;
|
|
147
|
+
}
|
|
148
|
+
function normalizeName(name) {
|
|
149
|
+
return name.trim().toLowerCase();
|
|
150
|
+
}
|
|
151
|
+
export async function listPeers() {
|
|
152
|
+
await ensureLoaded();
|
|
153
|
+
return Object.values(cache).sort((a, b) => a.priority - b.priority || a.createdAt - b.createdAt);
|
|
154
|
+
}
|
|
155
|
+
export async function getPeer(name) {
|
|
156
|
+
await ensureLoaded();
|
|
157
|
+
return cache[normalizeName(name)];
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Peers worth trying right now, in the order they should be tried.
|
|
161
|
+
*
|
|
162
|
+
* Disabled and cooling peers are dropped rather than sorted last: a borrowed
|
|
163
|
+
* request is already the fallback path, and spending its latency budget on a
|
|
164
|
+
* peer that just said "paused" helps nobody.
|
|
165
|
+
*/
|
|
166
|
+
export async function selectBorrowablePeers(now = Date.now()) {
|
|
167
|
+
const peers = await listPeers();
|
|
168
|
+
return peers.filter((peer) => peer.enabled && !(peer.cooldownUntil && peer.cooldownUntil > now));
|
|
169
|
+
}
|
|
170
|
+
export async function addPeer(input) {
|
|
171
|
+
return mutationMutex.runExclusive(async () => {
|
|
172
|
+
await ensureLoaded({ force: true });
|
|
173
|
+
const key = normalizeName(input.name);
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
const existing = cache[key];
|
|
176
|
+
const peer = {
|
|
177
|
+
schemaVersion: 1,
|
|
178
|
+
name: input.name.trim(),
|
|
179
|
+
url: input.url.replace(/\/+$/, ""),
|
|
180
|
+
token: input.token,
|
|
181
|
+
...(input.receiptSecret
|
|
182
|
+
? { receiptSecret: input.receiptSecret }
|
|
183
|
+
: existing?.receiptSecret
|
|
184
|
+
? { receiptSecret: existing.receiptSecret }
|
|
185
|
+
: {}),
|
|
186
|
+
priority: input.priority ?? existing?.priority ?? 100,
|
|
187
|
+
enabled: existing?.enabled ?? true,
|
|
188
|
+
createdAt: existing?.createdAt ?? now,
|
|
189
|
+
updatedAt: now,
|
|
190
|
+
...(input.note ? { note: input.note } : {}),
|
|
191
|
+
};
|
|
192
|
+
cache[key] = peer;
|
|
193
|
+
await persist();
|
|
194
|
+
return peer;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
export async function removePeer(name) {
|
|
198
|
+
return mutationMutex.runExclusive(async () => {
|
|
199
|
+
await ensureLoaded({ force: true });
|
|
200
|
+
const key = normalizeName(name);
|
|
201
|
+
if (!cache[key]) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
delete cache[key];
|
|
205
|
+
await persist();
|
|
206
|
+
return true;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
export async function setPeerEnabled(name, enabled) {
|
|
210
|
+
return mutationMutex.runExclusive(async () => {
|
|
211
|
+
await ensureLoaded({ force: true });
|
|
212
|
+
const key = normalizeName(name);
|
|
213
|
+
const peer = cache[key];
|
|
214
|
+
if (!peer) {
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
const updated = {
|
|
218
|
+
...peer,
|
|
219
|
+
enabled,
|
|
220
|
+
updatedAt: Date.now(),
|
|
221
|
+
// Re-enabling clears the cooldown: the operator is explicitly saying to
|
|
222
|
+
// try again, and making them wait out a timer they can see would be a
|
|
223
|
+
// control that does not control anything.
|
|
224
|
+
...(enabled ? { cooldownUntil: 0 } : {}),
|
|
225
|
+
};
|
|
226
|
+
cache[key] = updated;
|
|
227
|
+
await persist();
|
|
228
|
+
return updated;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
export async function updatePeer(name, patch) {
|
|
232
|
+
return mutationMutex.runExclusive(async () => {
|
|
233
|
+
await ensureLoaded({ force: true });
|
|
234
|
+
const key = normalizeName(name);
|
|
235
|
+
const peer = cache[key];
|
|
236
|
+
if (!peer) {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
const updated = {
|
|
240
|
+
...peer,
|
|
241
|
+
...(patch.priority !== undefined ? { priority: patch.priority } : {}),
|
|
242
|
+
...(patch.note !== undefined ? { note: patch.note } : {}),
|
|
243
|
+
...(patch.url ? { url: patch.url.replace(/\/+$/, "") } : {}),
|
|
244
|
+
...(patch.token ? { token: patch.token } : {}),
|
|
245
|
+
...(patch.receiptSecret ? { receiptSecret: patch.receiptSecret } : {}),
|
|
246
|
+
...(patch.reciprocalPeer ? { reciprocalPeer: patch.reciprocalPeer } : {}),
|
|
247
|
+
...(patch.lastReceiptSequence !== undefined
|
|
248
|
+
? { lastReceiptSequence: patch.lastReceiptSequence }
|
|
249
|
+
: {}),
|
|
250
|
+
updatedAt: Date.now(),
|
|
251
|
+
};
|
|
252
|
+
if (patch.pendingProvision !== undefined) {
|
|
253
|
+
if (patch.pendingProvision === null) {
|
|
254
|
+
delete updated.pendingProvision;
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
updated.pendingProvision = patch.pendingProvision;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
cache[key] = updated;
|
|
261
|
+
await persist();
|
|
262
|
+
return updated;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
/** How long a peer should be left alone after this kind of refusal. */
|
|
266
|
+
export function peerCooldownMs(reason) {
|
|
267
|
+
return COOLDOWN_MS_BY_REASON[reason];
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The longest a peer is ever parked.
|
|
271
|
+
*
|
|
272
|
+
* A lender legitimately says "a week" when a weekly window is what recovers, but
|
|
273
|
+
* `retry-after` is a number from another machine and a malformed one would park
|
|
274
|
+
* a working peer effectively forever.
|
|
275
|
+
*/
|
|
276
|
+
const MAX_COOLDOWN_MS = 604_800_000;
|
|
277
|
+
/**
|
|
278
|
+
* Park a peer after a refusal.
|
|
279
|
+
*
|
|
280
|
+
* `retryAfterSeconds` from the lender wins when it is longer than our default —
|
|
281
|
+
* the lender knows when its window turns over and we do not — up to a week.
|
|
282
|
+
*/
|
|
283
|
+
export async function coolPeer(name, reason, retryAfterSeconds) {
|
|
284
|
+
await mutationMutex.runExclusive(async () => {
|
|
285
|
+
// Force: `persist()` writes the whole map back, so a cooldown recorded on a
|
|
286
|
+
// TTL-fresh snapshot would revert a token rotation or a removal the CLI made
|
|
287
|
+
// in the window since this process last read the file.
|
|
288
|
+
await ensureLoaded({ force: true });
|
|
289
|
+
const key = normalizeName(name);
|
|
290
|
+
const peer = cache[key];
|
|
291
|
+
if (!peer) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const suggested = (retryAfterSeconds ?? 0) * 1000;
|
|
295
|
+
const until = Date.now() +
|
|
296
|
+
Math.min(MAX_COOLDOWN_MS, Math.max(peerCooldownMs(reason), Number.isFinite(suggested) ? suggested : 0));
|
|
297
|
+
cache[key] = {
|
|
298
|
+
...peer,
|
|
299
|
+
cooldownUntil: until,
|
|
300
|
+
cooldownReason: reason,
|
|
301
|
+
updatedAt: Date.now(),
|
|
302
|
+
};
|
|
303
|
+
await persist();
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
/** Clear a cooldown after a peer serves successfully. */
|
|
307
|
+
export async function recordPeerSuccess(name, observation) {
|
|
308
|
+
await mutationMutex.runExclusive(async () => {
|
|
309
|
+
await ensureLoaded({ force: true });
|
|
310
|
+
const key = normalizeName(name);
|
|
311
|
+
const peer = cache[key];
|
|
312
|
+
if (!peer) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
cache[key] = {
|
|
316
|
+
...peer,
|
|
317
|
+
cooldownUntil: 0,
|
|
318
|
+
lastUsedAt: Date.now(),
|
|
319
|
+
updatedAt: Date.now(),
|
|
320
|
+
...(observation ? { lastObservation: observation } : {}),
|
|
321
|
+
};
|
|
322
|
+
await persist();
|
|
323
|
+
});
|
|
324
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forwarding a borrowed request to a lender's proxy.
|
|
3
|
+
*
|
|
4
|
+
* The wire format is unchanged Anthropic Messages in both directions, so this is
|
|
5
|
+
* a passthrough, not a translation: the lender's proxy speaks exactly what the
|
|
6
|
+
* borrower's client already sent. That is what makes peer borrowing cheap
|
|
7
|
+
* compared with the provider fallback chain, which has to reshape the request
|
|
8
|
+
* for a different API.
|
|
9
|
+
*
|
|
10
|
+
* **Reading the refusal, not the status.** A lender's 429 can mean "your grant
|
|
11
|
+
* is spent" or "the upstream throttled me"; those want opposite reactions from
|
|
12
|
+
* the borrower. The distinction is carried in `x-neurolink-grant-reason`, so
|
|
13
|
+
* that header — not the status code — decides how long the peer is parked.
|
|
14
|
+
*
|
|
15
|
+
* @module proxy/peerTransport
|
|
16
|
+
*/
|
|
17
|
+
import type { ProxyPeer, ProxyPeerAttempt, ProxyPeerCooldownReason } from "../types/index.js";
|
|
18
|
+
/**
|
|
19
|
+
* Map a lender's refusal to how long the peer should be left alone.
|
|
20
|
+
*
|
|
21
|
+
* Anything not recognized is treated as an upstream problem rather than a grant
|
|
22
|
+
* problem — the conservative reading, since it recovers soonest.
|
|
23
|
+
*/
|
|
24
|
+
export declare function peerReasonFromRefusal(grantReason: string | null): ProxyPeerCooldownReason;
|
|
25
|
+
/**
|
|
26
|
+
* Send one request to one peer.
|
|
27
|
+
*
|
|
28
|
+
* On success the upstream `Response` is handed back with only an idle deadline
|
|
29
|
+
* wrapped around its body, so a stream keeps streaming — buffering it here
|
|
30
|
+
* would add the whole generation time to time-to-first-token on a path that is
|
|
31
|
+
* already a second hop.
|
|
32
|
+
*/
|
|
33
|
+
export declare function forwardToPeer(args: {
|
|
34
|
+
peer: ProxyPeer;
|
|
35
|
+
body: string;
|
|
36
|
+
stream: boolean;
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
}): Promise<ProxyPeerAttempt>;
|