@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.
- package/CHANGELOG.md +3 -3
- 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/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/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
|
|
@@ -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
|
+
}
|