@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +54 -2
  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/core/baseProvider.js +20 -1
  15. package/dist/neurolink.js +30 -2
  16. package/dist/proxy/peerStore.d.ts +52 -0
  17. package/dist/proxy/peerStore.js +324 -0
  18. package/dist/proxy/peerTransport.d.ts +38 -0
  19. package/dist/proxy/peerTransport.js +242 -0
  20. package/dist/proxy/proxyPaths.d.ts +8 -0
  21. package/dist/proxy/proxyPaths.js +55 -17
  22. package/dist/proxy/requestLogger.js +8 -0
  23. package/dist/proxy/residentGrants.d.ts +57 -0
  24. package/dist/proxy/residentGrants.js +393 -0
  25. package/dist/proxy/shareAudit.d.ts +81 -0
  26. package/dist/proxy/shareAudit.js +280 -0
  27. package/dist/proxy/shareContext.d.ts +38 -0
  28. package/dist/proxy/shareContext.js +92 -0
  29. package/dist/proxy/shareGate.d.ts +64 -0
  30. package/dist/proxy/shareGate.js +216 -0
  31. package/dist/proxy/shareGrants.d.ts +115 -0
  32. package/dist/proxy/shareGrants.js +590 -0
  33. package/dist/proxy/shareLease.d.ts +101 -0
  34. package/dist/proxy/shareLease.js +192 -0
  35. package/dist/proxy/shareLedger.d.ts +105 -0
  36. package/dist/proxy/shareLedger.js +406 -0
  37. package/dist/proxy/shareListener.d.ts +60 -0
  38. package/dist/proxy/shareListener.js +143 -0
  39. package/dist/proxy/shareNotes.d.ts +97 -0
  40. package/dist/proxy/shareNotes.js +234 -0
  41. package/dist/proxy/sharePolicy.d.ts +110 -0
  42. package/dist/proxy/sharePolicy.js +366 -0
  43. package/dist/proxy/shareProvisioning.d.ts +110 -0
  44. package/dist/proxy/shareProvisioning.js +237 -0
  45. package/dist/proxy/shareReceipts.d.ts +99 -0
  46. package/dist/proxy/shareReceipts.js +303 -0
  47. package/dist/proxy/shareSigning.d.ts +40 -0
  48. package/dist/proxy/shareSigning.js +78 -0
  49. package/dist/server/routes/claudeProxyRoutes.js +1066 -3
  50. package/dist/types/cli.d.ts +61 -0
  51. package/dist/types/proxy.d.ts +781 -0
  52. package/dist/utils/streamCancellation.d.ts +46 -0
  53. package/dist/utils/streamCancellation.js +90 -0
  54. package/dist/utils/ttsStream.js +34 -1
  55. package/dist/voice/livekit/realtimeEventBridge.js +27 -6
  56. 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
@@ -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 originalStream) {
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
- void this.toolRegistry.registerTool(toolId, toolInfo, {
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>;