@juspay/neurolink 11.29.2 → 11.30.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/auth/anthropicOAuth.d.ts +50 -0
  3. package/dist/auth/anthropicOAuth.js +78 -0
  4. package/dist/browser/neurolink.min.js +393 -393
  5. package/dist/cli/commands/proxy.d.ts +2 -0
  6. package/dist/cli/commands/proxy.js +284 -4
  7. package/dist/cli/commands/proxyExpose.d.ts +35 -0
  8. package/dist/cli/commands/proxyExpose.js +252 -0
  9. package/dist/cli/commands/proxyPeer.d.ts +29 -0
  10. package/dist/cli/commands/proxyPeer.js +738 -0
  11. package/dist/cli/commands/proxyShare.d.ts +37 -0
  12. package/dist/cli/commands/proxyShare.js +1080 -0
  13. package/dist/cli/parser.js +7 -1
  14. package/dist/proxy/peerStore.d.ts +52 -0
  15. package/dist/proxy/peerStore.js +324 -0
  16. package/dist/proxy/peerTransport.d.ts +38 -0
  17. package/dist/proxy/peerTransport.js +242 -0
  18. package/dist/proxy/proxyPaths.d.ts +8 -0
  19. package/dist/proxy/proxyPaths.js +55 -17
  20. package/dist/proxy/requestLogger.js +8 -0
  21. package/dist/proxy/residentGrants.d.ts +57 -0
  22. package/dist/proxy/residentGrants.js +393 -0
  23. package/dist/proxy/shareAudit.d.ts +81 -0
  24. package/dist/proxy/shareAudit.js +280 -0
  25. package/dist/proxy/shareContext.d.ts +38 -0
  26. package/dist/proxy/shareContext.js +92 -0
  27. package/dist/proxy/shareGate.d.ts +64 -0
  28. package/dist/proxy/shareGate.js +216 -0
  29. package/dist/proxy/shareGrants.d.ts +115 -0
  30. package/dist/proxy/shareGrants.js +590 -0
  31. package/dist/proxy/shareLease.d.ts +101 -0
  32. package/dist/proxy/shareLease.js +192 -0
  33. package/dist/proxy/shareLedger.d.ts +105 -0
  34. package/dist/proxy/shareLedger.js +406 -0
  35. package/dist/proxy/shareListener.d.ts +60 -0
  36. package/dist/proxy/shareListener.js +143 -0
  37. package/dist/proxy/shareNotes.d.ts +97 -0
  38. package/dist/proxy/shareNotes.js +234 -0
  39. package/dist/proxy/sharePolicy.d.ts +110 -0
  40. package/dist/proxy/sharePolicy.js +366 -0
  41. package/dist/proxy/shareProvisioning.d.ts +110 -0
  42. package/dist/proxy/shareProvisioning.js +237 -0
  43. package/dist/proxy/shareReceipts.d.ts +99 -0
  44. package/dist/proxy/shareReceipts.js +303 -0
  45. package/dist/proxy/shareSigning.d.ts +40 -0
  46. package/dist/proxy/shareSigning.js +78 -0
  47. package/dist/server/routes/claudeProxyRoutes.js +1066 -3
  48. package/dist/types/cli.d.ts +61 -0
  49. package/dist/types/proxy.d.ts +781 -0
  50. package/package.json +2 -1
@@ -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
@@ -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>;
@@ -0,0 +1,242 @@
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 { logger } from "../utils/logger.js";
18
+ import { coolPeer, recordPeerSuccess } from "./peerStore.js";
19
+ /** A peer is a fallback, so it gets a short leash before we move on. */
20
+ const PEER_CONNECT_TIMEOUT_MS = 15_000;
21
+ /**
22
+ * Longest silence tolerated once a peer has started answering.
23
+ *
24
+ * The connect timer is cleared as soon as headers arrive, which on a streamed
25
+ * response is long before the answer is. Without a second deadline a lender
26
+ * that opens the stream and then stops writing holds our client open forever —
27
+ * a borrowed request is the fallback path and should never be the one that
28
+ * hangs. Generous, because a long thinking pause is a legitimate silence.
29
+ */
30
+ const PEER_IDLE_TIMEOUT_MS = 120_000;
31
+ /**
32
+ * Map a lender's refusal to how long the peer should be left alone.
33
+ *
34
+ * Anything not recognized is treated as an upstream problem rather than a grant
35
+ * problem — the conservative reading, since it recovers soonest.
36
+ */
37
+ export function peerReasonFromRefusal(grantReason) {
38
+ switch (grantReason) {
39
+ case "exhausted":
40
+ return "exhausted";
41
+ case "paused":
42
+ return "paused";
43
+ case "revoked":
44
+ return "revoked";
45
+ case "expired":
46
+ return "expired";
47
+ case "reserve_floor":
48
+ case "spillover_inactive":
49
+ case "slice_exhausted":
50
+ case "no_capacity":
51
+ return "withheld";
52
+ case "missing_token":
53
+ case "unknown_token":
54
+ case "malformed_token":
55
+ // The lender does not recognize us at all. Treat it like a revocation:
56
+ // retrying a token the lender has forgotten cannot start working again.
57
+ return "revoked";
58
+ default:
59
+ // No grant reason means the lender never got as far as our grant — this
60
+ // is its own upstream or credential trouble, not a statement about us.
61
+ // Reading a bare 401 as a revocation would park a perfectly good peer for
62
+ // a day because the lender briefly had no usable account.
63
+ return "upstream_error";
64
+ }
65
+ }
66
+ /**
67
+ * A numeric header, or `undefined` when the peer did not send one.
68
+ *
69
+ * `Number(null)` and `Number("")` are both `0`, and `0` passes
70
+ * `Number.isFinite` — so reading these headers directly turns "the lender said
71
+ * nothing" into "the lender said zero", which on a remaining-coins header reads
72
+ * as an exhausted peer.
73
+ */
74
+ function numericHeader(response, name) {
75
+ const raw = response.headers.get(name);
76
+ if (raw === null || raw.trim() === "") {
77
+ return undefined;
78
+ }
79
+ const value = Number(raw);
80
+ return Number.isFinite(value) ? value : undefined;
81
+ }
82
+ function observationFrom(response) {
83
+ const remaining = numericHeader(response, "x-neurolink-grant-remaining-coins");
84
+ return {
85
+ observedAt: Date.now(),
86
+ ...(response.headers.get("x-neurolink-grant-status")
87
+ ? { grantStatus: response.headers.get("x-neurolink-grant-status") ?? "" }
88
+ : {}),
89
+ ...(response.headers.get("x-neurolink-grant-reason")
90
+ ? { grantReason: response.headers.get("x-neurolink-grant-reason") ?? "" }
91
+ : {}),
92
+ ...(remaining !== undefined ? { remainingCoins: remaining } : {}),
93
+ };
94
+ }
95
+ /**
96
+ * Re-arm a deadline on every chunk, and abandon the peer if one never comes.
97
+ *
98
+ * The body is passed through rather than buffered: the point of handing the
99
+ * upstream response back is that a stream keeps streaming, and collecting it
100
+ * here would add the whole generation time to time-to-first-token on a path
101
+ * that is already a second hop.
102
+ */
103
+ function withIdleDeadline(response, peer, controller) {
104
+ const body = response.body;
105
+ if (!body) {
106
+ return response;
107
+ }
108
+ let idle;
109
+ const disarm = () => {
110
+ if (idle !== undefined) {
111
+ clearTimeout(idle);
112
+ idle = undefined;
113
+ }
114
+ };
115
+ const arm = () => {
116
+ disarm();
117
+ idle = setTimeout(() => {
118
+ logger.always(`[proxy] peer=${peer.name} went quiet mid-response; abandoning it`);
119
+ // Cool it here as well. The success path cleared this peer's cooldown the
120
+ // moment the response headers arrived, which is long before a body stops
121
+ // arriving — so a peer that answers 200 and then stalls would otherwise
122
+ // stay perfectly healthy in the store and be picked again, and again, for
123
+ // the same stall. Failing mid-body is a failure like any other.
124
+ void coolPeer(peer.name, "unreachable").catch((error) => {
125
+ logger.debug(`[proxy] could not cool peer=${peer.name} after a stall: ${error instanceof Error ? error.message : String(error)}`);
126
+ });
127
+ // Aborting the fetch errors the stream, which is what the caller needs to
128
+ // see — a truncated answer presented as a complete one would be worse.
129
+ controller.abort();
130
+ }, PEER_IDLE_TIMEOUT_MS);
131
+ idle.unref?.();
132
+ };
133
+ const watched = body.pipeThrough(new TransformStream({
134
+ start: arm,
135
+ transform(chunk, target) {
136
+ arm();
137
+ target.enqueue(chunk);
138
+ },
139
+ flush: disarm,
140
+ }));
141
+ controller.signal.addEventListener("abort", disarm, { once: true });
142
+ return new Response(watched, {
143
+ status: response.status,
144
+ statusText: response.statusText,
145
+ headers: response.headers,
146
+ });
147
+ }
148
+ /**
149
+ * Send one request to one peer.
150
+ *
151
+ * On success the upstream `Response` is handed back with only an idle deadline
152
+ * wrapped around its body, so a stream keeps streaming — buffering it here
153
+ * would add the whole generation time to time-to-first-token on a path that is
154
+ * already a second hop.
155
+ */
156
+ export async function forwardToPeer(args) {
157
+ const { peer, body, stream } = args;
158
+ const controller = new AbortController();
159
+ const timeout = setTimeout(() => controller.abort(), PEER_CONNECT_TIMEOUT_MS);
160
+ if (args.signal) {
161
+ if (args.signal.aborted) {
162
+ controller.abort();
163
+ }
164
+ else {
165
+ args.signal.addEventListener("abort", () => controller.abort(), {
166
+ once: true,
167
+ });
168
+ }
169
+ }
170
+ try {
171
+ const response = await fetch(`${peer.url}/v1/messages`, {
172
+ method: "POST",
173
+ headers: {
174
+ "content-type": "application/json",
175
+ "x-neurolink-share-token": peer.token,
176
+ accept: stream ? "text/event-stream" : "application/json",
177
+ },
178
+ body,
179
+ signal: controller.signal,
180
+ });
181
+ if (response.ok) {
182
+ // Outside the classification below on purpose. This is bookkeeping over a
183
+ // response that already succeeded, and a disk that will not take the note
184
+ // says nothing about the peer — letting it fall through to the catch
185
+ // would cool a peer that had just answered correctly.
186
+ await recordPeerSuccess(peer.name, observationFrom(response)).catch((error) => {
187
+ logger.debug(`[proxy] could not record success for peer=${peer.name}: ${error instanceof Error ? error.message : String(error)}`);
188
+ });
189
+ return {
190
+ ok: true,
191
+ response: withIdleDeadline(response, peer, controller),
192
+ peer,
193
+ };
194
+ }
195
+ const grantReason = response.headers.get("x-neurolink-grant-reason");
196
+ const reason = peerReasonFromRefusal(grantReason);
197
+ const retryAfter = numericHeader(response, "retry-after");
198
+ await coolPeer(peer.name, reason, retryAfter);
199
+ // The body is drained but deliberately not surfaced: it is the lender's
200
+ // wording about the lender's pool, and forwarding it to our client would
201
+ // leak their account state into an error our client cannot act on.
202
+ await response.text().catch(() => "");
203
+ logger.always(`[proxy] peer=${peer.name} declined (${reason}); cooling before retry`);
204
+ return {
205
+ ok: false,
206
+ peer,
207
+ status: response.status,
208
+ reason,
209
+ message: `peer ${peer.name} declined: ${reason}`,
210
+ ...(retryAfter !== undefined ? { retryAfterSeconds: retryAfter } : {}),
211
+ };
212
+ }
213
+ catch (error) {
214
+ const message = error instanceof Error ? error.message : String(error);
215
+ if (args.signal?.aborted) {
216
+ // Our own client hung up, and the abort we are catching is the one we
217
+ // forwarded on its behalf. The peer did nothing wrong — cooling it here
218
+ // would let a client that cancels quickly take a whole mesh out of
219
+ // rotation one peer per cancellation.
220
+ logger.debug(`[proxy] peer=${peer.name} attempt cancelled by the caller: ${message}`);
221
+ return {
222
+ ok: false,
223
+ peer,
224
+ reason: "unreachable",
225
+ message: `peer ${peer.name} attempt cancelled`,
226
+ };
227
+ }
228
+ await coolPeer(peer.name, "unreachable").catch((coolError) => {
229
+ logger.debug(`[proxy] could not cool peer=${peer.name}: ${coolError instanceof Error ? coolError.message : String(coolError)}`);
230
+ });
231
+ logger.always(`[proxy] peer=${peer.name} unreachable: ${message}`);
232
+ return {
233
+ ok: false,
234
+ peer,
235
+ reason: "unreachable",
236
+ message: `peer ${peer.name} unreachable`,
237
+ };
238
+ }
239
+ finally {
240
+ clearTimeout(timeout);
241
+ }
242
+ }