@juspay/neurolink 9.92.2 → 9.93.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.
@@ -0,0 +1,332 @@
1
+ import { createHash, createHmac, randomBytes, randomUUID } from "node:crypto";
2
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { appendFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { performance } from "node:perf_hooks";
6
+ import { withTimeout } from "../utils/async/withTimeout.js";
7
+ import { logger } from "../utils/logger.js";
8
+ const SCHEMA_VERSION = 1;
9
+ const DEFAULT_QUEUE_CAPACITY = 10_000;
10
+ const DEFAULT_BATCH_SIZE = 256;
11
+ const DEFAULT_FLUSH_INTERVAL_MS = 25;
12
+ const LIFECYCLE_APPEND_TIMEOUT_MS = 2_000;
13
+ const MAX_SHORT_FIELD_LENGTH = 256;
14
+ const SESSION_KEY_FILE = ".proxy-lifecycle-session-key";
15
+ let loggerEnabled = false;
16
+ let lifecycleLogDir;
17
+ let queueCapacity = DEFAULT_QUEUE_CAPACITY;
18
+ let batchSize = DEFAULT_BATCH_SIZE;
19
+ let flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS;
20
+ let processInstanceId = randomUUID();
21
+ let sessionHashKey = randomBytes(32);
22
+ let nextSequence = 1;
23
+ let attempted = 0;
24
+ let enqueued = 0;
25
+ let written = 0;
26
+ let dropped = 0;
27
+ let queueDrops = 0;
28
+ let invalidDrops = 0;
29
+ let writeDrops = 0;
30
+ let writeFailures = 0;
31
+ let inFlight = 0;
32
+ let queue = [];
33
+ let flushTimer;
34
+ let flushInFlight;
35
+ function positiveInteger(value, fallback) {
36
+ return Number.isInteger(value) && (value ?? 0) > 0
37
+ ? value
38
+ : fallback;
39
+ }
40
+ function clip(value) {
41
+ if (typeof value !== "string") {
42
+ return undefined;
43
+ }
44
+ return value.length <= MAX_SHORT_FIELD_LENGTH
45
+ ? value
46
+ : value.slice(0, MAX_SHORT_FIELD_LENGTH);
47
+ }
48
+ function finiteNonNegative(value) {
49
+ return value !== undefined && Number.isFinite(value) && value >= 0
50
+ ? value
51
+ : undefined;
52
+ }
53
+ function nonNegativeInteger(value) {
54
+ const finite = finiteNonNegative(value);
55
+ return finite === undefined ? undefined : Math.floor(finite);
56
+ }
57
+ function formatTimestamp(timestampMs) {
58
+ const candidate = typeof timestampMs === "number" && Number.isFinite(timestampMs)
59
+ ? timestampMs
60
+ : Date.now();
61
+ const date = new Date(candidate);
62
+ return Number.isFinite(date.getTime())
63
+ ? date.toISOString()
64
+ : new Date().toISOString();
65
+ }
66
+ function readPersistedSessionHashKey(path) {
67
+ try {
68
+ const key = Buffer.from(readFileSync(path, "utf8").trim(), "base64");
69
+ if (key.length !== 32) {
70
+ throw new Error("persisted lifecycle session key must contain 32 bytes");
71
+ }
72
+ chmodSync(path, 0o600);
73
+ return key;
74
+ }
75
+ catch (error) {
76
+ if (error.code === "ENOENT") {
77
+ return undefined;
78
+ }
79
+ throw error;
80
+ }
81
+ }
82
+ function resolveSessionHashKey(logDir) {
83
+ const configuredSecret = process.env.NEUROLINK_PROXY_SESSION_SECRET?.trim();
84
+ if (configuredSecret) {
85
+ return createHash("sha256").update(configuredSecret).digest();
86
+ }
87
+ const keyPath = join(logDir, SESSION_KEY_FILE);
88
+ const existing = readPersistedSessionHashKey(keyPath);
89
+ if (existing) {
90
+ return existing;
91
+ }
92
+ const generated = randomBytes(32);
93
+ try {
94
+ writeFileSync(keyPath, generated.toString("base64"), {
95
+ encoding: "utf8",
96
+ flag: "wx",
97
+ mode: 0o600,
98
+ });
99
+ return generated;
100
+ }
101
+ catch (error) {
102
+ if (error.code === "EEXIST") {
103
+ const concurrentlyCreated = readPersistedSessionHashKey(keyPath);
104
+ if (concurrentlyCreated) {
105
+ return concurrentlyCreated;
106
+ }
107
+ }
108
+ throw error;
109
+ }
110
+ }
111
+ export function hashProxyLifecycleSessionId(sessionId) {
112
+ if (!sessionId) {
113
+ return undefined;
114
+ }
115
+ return createHmac("sha256", sessionHashKey)
116
+ .update(sessionId)
117
+ .digest("hex")
118
+ .slice(0, 24);
119
+ }
120
+ function clearScheduledFlush() {
121
+ if (flushTimer) {
122
+ clearTimeout(flushTimer);
123
+ flushTimer = undefined;
124
+ }
125
+ }
126
+ function scheduleFlush() {
127
+ if (flushTimer || flushInFlight || queue.length === 0) {
128
+ return;
129
+ }
130
+ flushTimer = setTimeout(() => {
131
+ flushTimer = undefined;
132
+ void startFlush();
133
+ }, flushIntervalMs);
134
+ flushTimer.unref?.();
135
+ }
136
+ async function flushBatch() {
137
+ if (queue.length === 0) {
138
+ return;
139
+ }
140
+ const batch = queue.splice(0, batchSize);
141
+ inFlight += batch.length;
142
+ try {
143
+ const byPath = new Map();
144
+ for (const item of batch) {
145
+ const path = join(item.logDir, `proxy-lifecycle-${item.date}.jsonl`);
146
+ const lines = byPath.get(path) ?? [];
147
+ lines.push(`${JSON.stringify(item.record)}\n`);
148
+ byPath.set(path, lines);
149
+ }
150
+ for (const [path, lines] of byPath) {
151
+ try {
152
+ // This best-effort telemetry sink intentionally avoids fsync so request
153
+ // throughput is not coupled to storage latency. Loss is surfaced by
154
+ // writeDrops/writeFailures rather than delaying proxy responses.
155
+ await withTimeout(appendFile(path, lines.join(""), { mode: 0o600 }), LIFECYCLE_APPEND_TIMEOUT_MS, "Timed out writing proxy lifecycle metadata");
156
+ written += lines.length;
157
+ }
158
+ catch (error) {
159
+ dropped += lines.length;
160
+ writeDrops += lines.length;
161
+ writeFailures += 1;
162
+ logger.warn("[proxy] lifecycle metadata write failed", {
163
+ error: error instanceof Error ? error.message : String(error),
164
+ });
165
+ }
166
+ }
167
+ }
168
+ finally {
169
+ inFlight = Math.max(0, inFlight - batch.length);
170
+ }
171
+ }
172
+ function startFlush() {
173
+ if (flushInFlight) {
174
+ return flushInFlight;
175
+ }
176
+ const currentFlush = flushBatch();
177
+ flushInFlight = currentFlush;
178
+ void currentFlush.then(() => {
179
+ if (flushInFlight === currentFlush) {
180
+ flushInFlight = undefined;
181
+ }
182
+ scheduleFlush();
183
+ }, () => {
184
+ if (flushInFlight === currentFlush) {
185
+ flushInFlight = undefined;
186
+ }
187
+ scheduleFlush();
188
+ });
189
+ return currentFlush;
190
+ }
191
+ export function configureProxyLifecycleLogger(options) {
192
+ clearScheduledFlush();
193
+ loggerEnabled = false;
194
+ lifecycleLogDir = undefined;
195
+ queueCapacity = positiveInteger(options.queueCapacity, DEFAULT_QUEUE_CAPACITY);
196
+ batchSize = positiveInteger(options.batchSize, DEFAULT_BATCH_SIZE);
197
+ flushIntervalMs = positiveInteger(options.flushIntervalMs, DEFAULT_FLUSH_INTERVAL_MS);
198
+ if (options.enabled && options.logDir) {
199
+ try {
200
+ mkdirSync(options.logDir, { recursive: true, mode: 0o700 });
201
+ sessionHashKey = resolveSessionHashKey(options.logDir);
202
+ lifecycleLogDir = options.logDir;
203
+ loggerEnabled = true;
204
+ }
205
+ catch (error) {
206
+ logger.warn("[proxy] lifecycle metadata logging disabled", {
207
+ error: error instanceof Error ? error.message : String(error),
208
+ });
209
+ }
210
+ }
211
+ // Queued records retain their enqueue-time destination. Reconfiguration is
212
+ // therefore atomic for new events while an in-flight or pending batch safely
213
+ // drains to its original directory.
214
+ scheduleFlush();
215
+ }
216
+ /** Enqueue fixed-size lifecycle metadata without awaiting filesystem work. */
217
+ export function logProxyLifecycleEvent(input) {
218
+ if (!loggerEnabled || !lifecycleLogDir) {
219
+ return;
220
+ }
221
+ try {
222
+ attempted += 1;
223
+ const sequence = nextSequence++;
224
+ if (queue.length >= queueCapacity) {
225
+ dropped += 1;
226
+ queueDrops += 1;
227
+ return;
228
+ }
229
+ const timestamp = formatTimestamp(input.timestampMs);
230
+ const monotonicMs = finiteNonNegative(input.monotonicMs) ?? performance.now();
231
+ const toolCount = nonNegativeInteger(input.toolCount);
232
+ const requestBytes = nonNegativeInteger(input.requestBytes);
233
+ const responseStatus = nonNegativeInteger(input.responseStatus);
234
+ const observedBodyBytes = nonNegativeInteger(input.observedBodyBytes);
235
+ const responseChunks = nonNegativeInteger(input.responseChunks);
236
+ const elapsedMs = finiteNonNegative(input.elapsedMs);
237
+ const model = clip(input.model);
238
+ const sessionHash = clip(input.sessionHash);
239
+ const terminalOutcome = clip(input.terminalOutcome);
240
+ const errorType = clip(input.errorType);
241
+ const errorCode = clip(input.errorCode);
242
+ const record = {
243
+ schemaVersion: SCHEMA_VERSION,
244
+ timestamp,
245
+ monotonicMs: Number(monotonicMs.toFixed(3)),
246
+ processInstanceId,
247
+ sequence,
248
+ event: clip(input.event) ?? "unknown",
249
+ requestId: clip(input.requestId) ?? "unknown",
250
+ method: clip(input.method) ?? "unknown",
251
+ path: clip(input.path) ?? "unknown",
252
+ ...(model !== undefined ? { model } : {}),
253
+ ...(input.stream !== undefined ? { stream: input.stream } : {}),
254
+ ...(toolCount !== undefined ? { toolCount } : {}),
255
+ ...(sessionHash !== undefined ? { sessionHash } : {}),
256
+ ...(requestBytes !== undefined ? { requestBytes } : {}),
257
+ ...(responseStatus !== undefined ? { responseStatus } : {}),
258
+ ...(observedBodyBytes !== undefined ? { observedBodyBytes } : {}),
259
+ ...(responseChunks !== undefined ? { responseChunks } : {}),
260
+ ...(elapsedMs !== undefined
261
+ ? { elapsedMs: Number(elapsedMs.toFixed(3)) }
262
+ : {}),
263
+ ...(terminalOutcome !== undefined ? { terminalOutcome } : {}),
264
+ ...(errorType !== undefined ? { errorType } : {}),
265
+ ...(errorCode !== undefined ? { errorCode } : {}),
266
+ };
267
+ queue.push({
268
+ logDir: lifecycleLogDir,
269
+ date: String(record.timestamp).slice(0, 10),
270
+ record,
271
+ });
272
+ enqueued += 1;
273
+ scheduleFlush();
274
+ }
275
+ catch {
276
+ dropped += 1;
277
+ invalidDrops += 1;
278
+ }
279
+ }
280
+ export async function flushProxyLifecycleEvents() {
281
+ clearScheduledFlush();
282
+ while (queue.length > 0 || flushInFlight) {
283
+ if (flushInFlight) {
284
+ await flushInFlight;
285
+ }
286
+ else {
287
+ await startFlush();
288
+ }
289
+ clearScheduledFlush();
290
+ }
291
+ }
292
+ export function getProxyLifecycleLoggerSnapshot() {
293
+ return {
294
+ enabled: loggerEnabled,
295
+ schemaVersion: SCHEMA_VERSION,
296
+ processInstanceId,
297
+ nextSequence,
298
+ attempted,
299
+ enqueued,
300
+ written,
301
+ dropped,
302
+ queueDrops,
303
+ invalidDrops,
304
+ writeDrops,
305
+ writeFailures,
306
+ pending: queue.length,
307
+ inFlight,
308
+ flushing: flushInFlight !== undefined,
309
+ };
310
+ }
311
+ export function resetProxyLifecycleLoggerForTests() {
312
+ clearScheduledFlush();
313
+ loggerEnabled = false;
314
+ lifecycleLogDir = undefined;
315
+ queueCapacity = DEFAULT_QUEUE_CAPACITY;
316
+ batchSize = DEFAULT_BATCH_SIZE;
317
+ flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS;
318
+ processInstanceId = randomUUID();
319
+ sessionHashKey = randomBytes(32);
320
+ nextSequence = 1;
321
+ attempted = 0;
322
+ enqueued = 0;
323
+ written = 0;
324
+ dropped = 0;
325
+ queueDrops = 0;
326
+ invalidDrops = 0;
327
+ writeDrops = 0;
328
+ writeFailures = 0;
329
+ inFlight = 0;
330
+ queue = [];
331
+ flushInFlight = undefined;
332
+ }
@@ -15,6 +15,7 @@ import { promisify } from "util";
15
15
  import { gzip as gzipCallback } from "zlib";
16
16
  import { OtelBridge } from "../observability/otelBridge.js";
17
17
  import { SeverityNumber } from "@opentelemetry/api-logs";
18
+ import { configureProxyLifecycleLogger } from "./proxyLifecycle.js";
18
19
  let logDir = null;
19
20
  let logEnabled = false;
20
21
  /**
@@ -45,8 +46,11 @@ const SENSITIVE_HEADER_PATTERN = /token|secret|key|password|credential/i;
45
46
  /** JSON keys whose values should be redacted in request/response bodies. */
46
47
  const SENSITIVE_BODY_KEYS = /("(?:password|access_token|refresh_token|api_key|apiKey|secret|authorization|token|credential|x-api-key)"\s*:\s*)"(?:[^"\\]|\\.)*"/gi;
47
48
  export function initRequestLogger(enabled = true, customLogsDir) {
49
+ // Lifecycle metadata deliberately shares the request logger's enablement,
50
+ // directory permissions, retention boundary, and operator privacy control.
48
51
  logEnabled = enabled;
49
52
  if (!enabled) {
53
+ configureProxyLifecycleLogger({ enabled: false });
50
54
  return;
51
55
  }
52
56
  try {
@@ -55,10 +59,12 @@ export function initRequestLogger(enabled = true, customLogsDir) {
55
59
  mkdirSync(logDir, { recursive: true, mode: 0o700 });
56
60
  }
57
61
  chmodSync(logDir, 0o700);
62
+ configureProxyLifecycleLogger({ enabled: true, logDir });
58
63
  }
59
64
  catch (err) {
60
65
  logEnabled = false;
61
66
  logDir = null;
67
+ configureProxyLifecycleLogger({ enabled: false });
62
68
  logger.warn(`[proxy] Request logging disabled — failed to create log directory: ${err instanceof Error ? err.message : String(err)}`);
63
69
  }
64
70
  }
@@ -18,12 +18,12 @@ import type { AccountAllowlist, AccountCooldownPlan, AccountQuota, AnthropicAtte
18
18
  * no key is configured or the key cannot be matched (account disabled/
19
19
  * removed). The resolution is per-request because enabledAccounts membership
20
20
  * can shift between requests. */
21
- declare function resolveHomeIndex(enabledAccounts: ProxyPassthroughAccount[], primaryAccountKey?: string | undefined): number;
21
+ declare function resolveHomeIndex(enabledAccounts: ProxyPassthroughAccount[], primaryAccountKey: string | undefined): number;
22
22
  /** If the configured home primary is no longer cooling, reset
23
23
  * primaryAccountIndex back to its index so traffic returns to the preferred
24
24
  * account once its rate limit window expires. Called at the start of each
25
25
  * request. Home is resolved fresh per call via resolveHomeIndex. */
26
- declare function maybeResetPrimaryToHome(enabledAccounts: ProxyPassthroughAccount[], primaryAccountKey?: string | undefined): void;
26
+ declare function maybeResetPrimaryToHome(enabledAccounts: ProxyPassthroughAccount[], primaryAccountKey: string | undefined): void;
27
27
  declare function claimTransientRateLimitRetry(accountKey: string, coolingUntil: number, now?: number): number | undefined;
28
28
  declare function claimTransientCooldownAdmission(accountKey: string, coolingUntil: number, now?: number): number | undefined;
29
29
  declare function waitForTransientAccountAvailability(orderedAccounts: ProxyPassthroughAccount[]): Promise<ProxyPassthroughAccount[]>;
@@ -61,28 +61,30 @@ declare function planCooldownFor429(quota: AccountQuota | null, retryAfterMs: nu
61
61
  declare function seedRuntimeQuotasFromDisk(accounts: ProxyPassthroughAccount[]): Promise<void>;
62
62
  /**
63
63
  * Order accounts to MAXIMIZE quota utilization (fill-first, smart order):
64
- * spend the window that expires SOONEST first, so its about-to-reset
65
- * allowance isn't wasted, then move to accounts with longer-dated resets.
64
+ * spend the overall weekly allowance that expires SOONEST first, so quota
65
+ * cannot disappear while traffic is consuming a newer weekly window. The 5h
66
+ * window remains an availability boundary: a session at its soft limit is
67
+ * temporarily demoted until that session resets.
66
68
  *
67
69
  * Priority among usable accounts:
68
70
  * 1. no quota data yet — probe first: one request reveals its windows and
69
71
  * self-corrects the ordering. (Ranking unknowns last would starve them
70
72
  * forever: never picked → never observed → never comparable.)
71
73
  * 2. session headroom before session-saturated (>= soft limit or
72
- * "throttled") — saturated accounts then follow the same bucketed
73
- * session ordering below: soonest back in service first, weekly
74
- * deciding same-bucket ties.
75
- * 3. soonest SESSION (5h) reset — the capacity expiring soonest; compared
76
- * in tolerance buckets so near-simultaneous resets count as equal.
77
- * 4. soonest WEEKLY (7d) reset — decides between same-bucket sessions.
78
- * 5. highest weekly utilization — finish off the one closest to done
74
+ * "throttled") — do not re-hammer an urgent weekly account while its 5h
75
+ * capacity is temporarily unavailable.
76
+ * 3. soonest WEEKLY (7d) reset — consume the oldest overall allowance
77
+ * before it expires.
78
+ * 4. soonest SESSION (5h) reset — decides equal-weekly or fresh-weekly ties,
79
+ * using tolerance buckets for comparator stability.
80
+ * 5. highest weekly utilization — finish off the one closest to done.
79
81
  * 6. configured primary account, then insertion order
80
- * An account with NO ticking session window (fresh, not yet started) has
81
- * nothing expiring and sorts after ticking windows in step 3.
82
+ * Saturated pairs are ordered by soonest 5h recovery first, then weekly reset,
83
+ * because neither can consume more quota until session capacity returns.
82
84
  * Cooling/rejected accounts sort last, soonest-back-to-service first, as
83
85
  * last resort.
84
86
  */
85
- declare function orderAccountsByQuota(accounts: ProxyPassthroughAccount[], now: number, primaryKey?: string | undefined, sessionSoftLimit?: number, sessionResetToleranceMs?: number): ProxyPassthroughAccount[];
87
+ declare function orderAccountsByQuota(accounts: ProxyPassthroughAccount[], now: number, primaryKey: string | undefined, sessionSoftLimit?: number, sessionResetToleranceMs?: number): ProxyPassthroughAccount[];
86
88
  declare function trackUpstreamReadableStream(source: ReadableStream<Uint8Array>): {
87
89
  stream: ReadableStream<Uint8Array>;
88
90
  outcome: Promise<StreamTerminalOutcome>;
@@ -266,10 +268,6 @@ export declare const __testHooks: {
266
268
  resetEpochToMs: typeof resetEpochToMs;
267
269
  seedRuntimeQuotasFromDisk: typeof seedRuntimeQuotasFromDisk;
268
270
  getAccountRuntimeState: (key: string) => RuntimeAccountState | undefined;
269
- setConfiguredPrimaryAccountKey: (key: string | undefined) => void;
270
- getConfiguredPrimaryAccountKey: () => string | undefined;
271
- setConfiguredAccountAllowlist: (allowlist: AccountAllowlist | undefined) => void;
272
- getConfiguredAccountAllowlist: () => string[] | undefined;
273
271
  setPrimaryAccountIndex: (index: number) => void;
274
272
  getPrimaryAccountIndex: () => number;
275
273
  setAccountRuntimeState: (key: string, state: Partial<RuntimeAccountState>) => void;