@juspay/neurolink 9.92.3 → 9.93.1

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 +12 -0
  2. package/dist/browser/neurolink.min.js +379 -382
  3. package/dist/cli/commands/proxy.d.ts +5 -3
  4. package/dist/cli/commands/proxy.js +360 -69
  5. package/dist/cli/commands/proxyAnalyze.d.ts +3 -0
  6. package/dist/cli/commands/proxyAnalyze.js +147 -0
  7. package/dist/cli/parser.js +3 -1
  8. package/dist/lib/proxy/proxyActivity.d.ts +2 -2
  9. package/dist/lib/proxy/proxyActivity.js +49 -9
  10. package/dist/lib/proxy/proxyAnalysis.d.ts +3 -0
  11. package/dist/lib/proxy/proxyAnalysis.js +454 -0
  12. package/dist/lib/proxy/proxyHealth.d.ts +4 -0
  13. package/dist/lib/proxy/proxyHealth.js +21 -0
  14. package/dist/lib/proxy/proxyLifecycle.d.ts +8 -0
  15. package/dist/lib/proxy/proxyLifecycle.js +333 -0
  16. package/dist/lib/proxy/requestLogger.js +6 -0
  17. package/dist/lib/proxy/sseInterceptor.d.ts +9 -1
  18. package/dist/lib/proxy/sseInterceptor.js +6 -5
  19. package/dist/lib/proxy/streamOutcome.d.ts +3 -1
  20. package/dist/lib/proxy/streamOutcome.js +77 -0
  21. package/dist/lib/proxy/updateCoordinator.d.ts +7 -0
  22. package/dist/lib/proxy/updateCoordinator.js +60 -0
  23. package/dist/lib/proxy/updateState.d.ts +4 -0
  24. package/dist/lib/proxy/updateState.js +51 -0
  25. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +1 -0
  26. package/dist/lib/server/routes/claudeProxyRoutes.js +137 -35
  27. package/dist/lib/types/cli.d.ts +7 -0
  28. package/dist/lib/types/proxy.d.ts +269 -0
  29. package/dist/proxy/proxyActivity.d.ts +2 -2
  30. package/dist/proxy/proxyActivity.js +49 -9
  31. package/dist/proxy/proxyAnalysis.d.ts +3 -0
  32. package/dist/proxy/proxyAnalysis.js +453 -0
  33. package/dist/proxy/proxyHealth.d.ts +4 -0
  34. package/dist/proxy/proxyHealth.js +21 -0
  35. package/dist/proxy/proxyLifecycle.d.ts +8 -0
  36. package/dist/proxy/proxyLifecycle.js +332 -0
  37. package/dist/proxy/requestLogger.js +6 -0
  38. package/dist/proxy/sseInterceptor.d.ts +9 -1
  39. package/dist/proxy/sseInterceptor.js +6 -5
  40. package/dist/proxy/streamOutcome.d.ts +3 -1
  41. package/dist/proxy/streamOutcome.js +77 -0
  42. package/dist/proxy/updateCoordinator.d.ts +7 -0
  43. package/dist/proxy/updateCoordinator.js +59 -0
  44. package/dist/proxy/updateState.d.ts +4 -0
  45. package/dist/proxy/updateState.js +51 -0
  46. package/dist/server/routes/claudeProxyRoutes.d.ts +1 -0
  47. package/dist/server/routes/claudeProxyRoutes.js +137 -35
  48. package/dist/types/cli.d.ts +7 -0
  49. package/dist/types/proxy.d.ts +269 -0
  50. package/package.json +4 -1
@@ -0,0 +1,333 @@
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
+ }
333
+ //# sourceMappingURL=proxyLifecycle.js.map
@@ -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
  }
@@ -15,7 +15,15 @@
15
15
  * upstreamResponse.body.pipeThrough(stream).pipeTo(clientWritable);
16
16
  * const data = await telemetry; // resolves on stream end
17
17
  */
18
- import type { SSEInterceptorOptions, SSEInterceptorResult } from "../types/index.js";
18
+ import type { SSEInterceptorOptions, SSEInterceptorResult, ParsedSSEBuffer } from "../types/index.js";
19
+ /**
20
+ * Incrementally parse SSE events from a growing buffer of text.
21
+ *
22
+ * SSE events are separated by a blank line (`\n\n`). Each event consists of
23
+ * field lines (`event: ...`, `data: ...`). We consume complete events and
24
+ * return them, leaving any trailing partial event in the buffer.
25
+ */
26
+ export declare function extractSSEEvents(buffer: string): ParsedSSEBuffer;
19
27
  /**
20
28
  * Create an SSE interceptor that extracts telemetry from an Anthropic
21
29
  * streaming response while passing all bytes through unmodified.
@@ -35,22 +35,23 @@ const TRUNCATION_MARKER = "...[TRUNCATED]";
35
35
  * field lines (`event: ...`, `data: ...`). We consume complete events and
36
36
  * return them, leaving any trailing partial event in the buffer.
37
37
  */
38
- function extractSSEEvents(buffer) {
38
+ export function extractSSEEvents(buffer) {
39
39
  const events = [];
40
40
  // Split on double-newline boundaries. The last segment may be an
41
41
  // incomplete event if the chunk was split mid-event.
42
42
  let cursor = 0;
43
43
  while (cursor < buffer.length) {
44
- const boundary = buffer.indexOf("\n\n", cursor);
45
- if (boundary === -1) {
44
+ const boundaryMatch = /\r\n\r\n|\n\n|\r\r/.exec(buffer.slice(cursor));
45
+ if (!boundaryMatch || boundaryMatch.index === undefined) {
46
46
  // No more complete events — everything from cursor onward is partial.
47
47
  break;
48
48
  }
49
+ const boundary = cursor + boundaryMatch.index;
49
50
  const rawBlock = buffer.slice(cursor, boundary);
50
- cursor = boundary + 2; // skip past the \n\n
51
+ cursor = boundary + boundaryMatch[0].length;
51
52
  let eventType = "";
52
53
  let dataValue = "";
53
- const lines = rawBlock.split("\n");
54
+ const lines = rawBlock.split(/\r\n|\n|\r/);
54
55
  for (const line of lines) {
55
56
  if (line.startsWith("event: ")) {
56
57
  eventType = line.slice(7).trim();
@@ -1,3 +1,5 @@
1
- import type { StreamTerminalOutcome, StreamTerminalOutcomeTracker } from "../types/index.js";
1
+ import type { AnthropicStreamPreflightResult, StreamTerminalOutcome, StreamTerminalOutcomeTracker } from "../types/index.js";
2
+ /** Hold only pre-commit SSE frames so immediate upstream errors remain retryable. */
3
+ export declare function preflightAnthropicStream(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<AnthropicStreamPreflightResult>;
2
4
  export declare function createStreamTerminalOutcomeTracker(): StreamTerminalOutcomeTracker;
3
5
  export declare function mergeStreamTerminalOutcome(outcome: StreamTerminalOutcome, sseErrorMessage?: string): StreamTerminalOutcome;
@@ -1,3 +1,80 @@
1
+ import { extractSSEEvents } from "./sseInterceptor.js";
2
+ const STREAM_PREFLIGHT_MAX_BYTES = 64 * 1024;
3
+ const STREAM_PREFLIGHT_MAX_CHUNKS = 32;
4
+ function parseSSEError(data) {
5
+ try {
6
+ const parsed = JSON.parse(data);
7
+ return {
8
+ errorType: parsed.error?.type ?? parsed.type ?? "stream_error",
9
+ message: parsed.error?.message ?? parsed.message ?? "Upstream SSE error",
10
+ };
11
+ }
12
+ catch {
13
+ return {
14
+ errorType: "stream_error",
15
+ message: data.trim() || "Upstream SSE error",
16
+ };
17
+ }
18
+ }
19
+ function isSSEErrorEvent(event) {
20
+ if (event.event === "error") {
21
+ return true;
22
+ }
23
+ try {
24
+ const parsed = JSON.parse(event.data);
25
+ return parsed.type === "error" || parsed.error !== undefined;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ /** Hold only pre-commit SSE frames so immediate upstream errors remain retryable. */
32
+ export async function preflightAnthropicStream(reader) {
33
+ const decoder = new TextDecoder();
34
+ const chunks = [];
35
+ let parseBuffer = "";
36
+ let totalBytes = 0;
37
+ while (totalBytes < STREAM_PREFLIGHT_MAX_BYTES &&
38
+ chunks.length < STREAM_PREFLIGHT_MAX_CHUNKS) {
39
+ let result;
40
+ try {
41
+ result = await reader.read();
42
+ }
43
+ catch (error) {
44
+ return { kind: "transport_error", chunks, error };
45
+ }
46
+ if (result.done) {
47
+ parseBuffer += decoder.decode();
48
+ const { events } = extractSSEEvents(parseBuffer);
49
+ const errorEvent = events.find(isSSEErrorEvent);
50
+ if (errorEvent) {
51
+ return { kind: "sse_error", chunks, ...parseSSEError(errorEvent.data) };
52
+ }
53
+ return chunks.length > 0
54
+ ? { kind: "ready", chunks }
55
+ : { kind: "empty", chunks };
56
+ }
57
+ if (!result.value || result.value.length === 0) {
58
+ continue;
59
+ }
60
+ chunks.push(result.value);
61
+ totalBytes += result.value.byteLength;
62
+ parseBuffer += decoder.decode(result.value, { stream: true });
63
+ const parsed = extractSSEEvents(parseBuffer);
64
+ parseBuffer = parsed.remainder;
65
+ for (const event of parsed.events) {
66
+ if (isSSEErrorEvent(event)) {
67
+ return { kind: "sse_error", chunks, ...parseSSEError(event.data) };
68
+ }
69
+ if (event.event !== "ping" && (event.event || event.data)) {
70
+ return { kind: "ready", chunks };
71
+ }
72
+ }
73
+ }
74
+ return chunks.length > 0
75
+ ? { kind: "ready", chunks }
76
+ : { kind: "empty", chunks };
77
+ }
1
78
  export function createStreamTerminalOutcomeTracker() {
2
79
  let settled = false;
3
80
  let resolveOutcome;
@@ -0,0 +1,7 @@
1
+ import type { ProxyUpdateWindowOptions, ProxyUpdateWindowResult } from "../types/index.js";
2
+ /**
3
+ * Prefer a naturally quiet window, then briefly drain new inference traffic.
4
+ * Existing requests are never interrupted; a bounded drain failure is returned
5
+ * to the caller so admission can be reopened and retried later.
6
+ */
7
+ export declare function waitForProxyUpdateWindow(options: ProxyUpdateWindowOptions): Promise<ProxyUpdateWindowResult>;
@@ -0,0 +1,60 @@
1
+ function isQuiet(activity, quietThresholdMs, nowMs) {
2
+ if (activity.activeRequests > 0) {
3
+ return false;
4
+ }
5
+ if (!activity.lastActivityAt) {
6
+ return true;
7
+ }
8
+ const lastActivityMs = Date.parse(activity.lastActivityAt);
9
+ return (Number.isFinite(lastActivityMs) &&
10
+ nowMs - lastActivityMs >= quietThresholdMs);
11
+ }
12
+ /**
13
+ * Prefer a naturally quiet window, then briefly drain new inference traffic.
14
+ * Existing requests are never interrupted; a bounded drain failure is returned
15
+ * to the caller so admission can be reopened and retried later.
16
+ */
17
+ export async function waitForProxyUpdateWindow(options) {
18
+ const now = options.now ?? Date.now;
19
+ const wait = options.sleep ??
20
+ ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
21
+ const quietDeadline = now() + options.quietWaitMs;
22
+ while (now() < quietDeadline) {
23
+ if (options.isStopping()) {
24
+ return { ready: false, draining: false, reason: "stopping" };
25
+ }
26
+ if (!options.isParentAlive()) {
27
+ return { ready: false, draining: false, reason: "parent_stopped" };
28
+ }
29
+ const activity = await options.getActivity();
30
+ if (activity && isQuiet(activity, options.quietThresholdMs, now())) {
31
+ return { ready: true, draining: false };
32
+ }
33
+ options.onPhase?.("waiting_for_quiet", activity);
34
+ await wait(options.pollIntervalMs);
35
+ }
36
+ if (!(await options.setDraining(true))) {
37
+ // The control response may have been lost after the parent applied it.
38
+ // Treat state as unknown/draining so the caller always attempts resume.
39
+ return { ready: false, draining: true, reason: "drain_failed" };
40
+ }
41
+ const drainDeadline = now() + options.drainWaitMs;
42
+ while (now() < drainDeadline) {
43
+ if (options.isStopping()) {
44
+ return { ready: false, draining: true, reason: "stopping" };
45
+ }
46
+ if (!options.isParentAlive()) {
47
+ return { ready: false, draining: true, reason: "parent_stopped" };
48
+ }
49
+ const activity = await options.getActivity();
50
+ options.onPhase?.("draining", activity);
51
+ if (activity?.activeRequests === 0) {
52
+ return { ready: true, draining: true };
53
+ }
54
+ await wait(options.pollIntervalMs);
55
+ }
56
+ const activity = await options.getActivity();
57
+ options.onPhase?.("drain_timeout", activity);
58
+ return { ready: false, draining: true, reason: "drain_timeout" };
59
+ }
60
+ //# sourceMappingURL=updateCoordinator.js.map
@@ -54,6 +54,10 @@ export declare function recordUpdateInstalled(version: string, stateFilePath?: s
54
54
  export declare function abandonPendingUpdate(version: string, stateFilePath?: string): boolean;
55
55
  /** Persist a stage-specific updater failure so status remains actionable. */
56
56
  export declare function recordUpdateFailure(version: string, stage: NonNullable<UpdateState["lastFailure"]>["stage"], message: string, stateFilePath?: string): void;
57
+ /** Persist why a discovered update is waiting without treating it as a failure. */
58
+ export declare function recordUpdateDeferred(version: string, reason: NonNullable<UpdateState["deferredUpdate"]>["reason"], activeRequests: number | null, stateFilePath?: string): void;
59
+ /** Clear matching updater deferral metadata once work can proceed. */
60
+ export declare function clearUpdateDeferral(version?: string, stateFilePath?: string): boolean;
57
61
  /** Complete an updater-managed install after a manual or automatic restart. */
58
62
  export declare function reconcileRunningUpdate(runningVersion: string, stateFilePath?: string): boolean;
59
63
  /**
@@ -56,6 +56,26 @@ function isValidLastFailure(value) {
56
56
  UPDATE_FAILURE_STAGES.has(candidate.stage) &&
57
57
  typeof candidate.message === "string");
58
58
  }
59
+ function isValidDeferredUpdate(value) {
60
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
61
+ return false;
62
+ }
63
+ const candidate = value;
64
+ return (typeof candidate.version === "string" &&
65
+ typeof candidate.since === "string" &&
66
+ typeof candidate.updatedAt === "string" &&
67
+ [
68
+ "waiting_for_quiet",
69
+ "draining",
70
+ "drain_timeout",
71
+ "drain_unavailable",
72
+ "activity_unavailable",
73
+ ].includes(String(candidate.reason)) &&
74
+ (candidate.activeRequests === null ||
75
+ (typeof candidate.activeRequests === "number" &&
76
+ Number.isInteger(candidate.activeRequests) &&
77
+ candidate.activeRequests >= 0)));
78
+ }
59
79
  // ============================================
60
80
  // Exported Functions
61
81
  // ============================================
@@ -70,6 +90,7 @@ export function getDefaultUpdateState() {
70
90
  lastUpdateAt: null,
71
91
  lastUpdateVersion: null,
72
92
  pendingRestartVersion: null,
93
+ deferredUpdate: null,
73
94
  lastFailure: null,
74
95
  };
75
96
  }
@@ -106,6 +127,9 @@ export function loadUpdateState(stateFilePath) {
106
127
  pendingRestartVersion: typeof candidate.pendingRestartVersion === "string"
107
128
  ? candidate.pendingRestartVersion
108
129
  : null,
130
+ deferredUpdate: isValidDeferredUpdate(candidate.deferredUpdate)
131
+ ? candidate.deferredUpdate
132
+ : null,
109
133
  lastFailure: isValidLastFailure(candidate.lastFailure)
110
134
  ? candidate.lastFailure
111
135
  : null,
@@ -185,6 +209,7 @@ export function recordSuccessfulUpdate(version, stateFilePath) {
185
209
  state.lastUpdateAt = new Date().toISOString();
186
210
  state.lastUpdateVersion = version;
187
211
  state.pendingRestartVersion = null;
212
+ state.deferredUpdate = null;
188
213
  state.lastFailure = null;
189
214
  delete state.suppressedVersions[version];
190
215
  saveUpdateState(state, stateFilePath);
@@ -217,6 +242,32 @@ export function recordUpdateFailure(version, stage, message, stateFilePath) {
217
242
  };
218
243
  saveUpdateState(state, stateFilePath);
219
244
  }
245
+ /** Persist why a discovered update is waiting without treating it as a failure. */
246
+ export function recordUpdateDeferred(version, reason, activeRequests, stateFilePath) {
247
+ const state = loadUpdateState(stateFilePath) ?? getDefaultUpdateState();
248
+ const now = new Date().toISOString();
249
+ state.deferredUpdate = {
250
+ version,
251
+ since: state.deferredUpdate?.version === version
252
+ ? state.deferredUpdate.since
253
+ : now,
254
+ updatedAt: now,
255
+ reason,
256
+ activeRequests,
257
+ };
258
+ saveUpdateState(state, stateFilePath);
259
+ }
260
+ /** Clear matching updater deferral metadata once work can proceed. */
261
+ export function clearUpdateDeferral(version, stateFilePath) {
262
+ const state = loadUpdateState(stateFilePath);
263
+ if (!state?.deferredUpdate ||
264
+ (version !== undefined && state.deferredUpdate.version !== version)) {
265
+ return false;
266
+ }
267
+ state.deferredUpdate = null;
268
+ saveUpdateState(state, stateFilePath);
269
+ return true;
270
+ }
220
271
  /** Complete an updater-managed install after a manual or automatic restart. */
221
272
  export function reconcileRunningUpdate(runningVersion, stateFilePath) {
222
273
  const state = loadUpdateState(stateFilePath);
@@ -143,6 +143,7 @@ declare function handleAnthropicStreamingSuccessResponse(args: {
143
143
  attemptNumber: number;
144
144
  finalBodyStr: string;
145
145
  upstreamSpan?: import("@opentelemetry/api").Span;
146
+ logAttempt: AnthropicAttemptLogger;
146
147
  logProxyBody: ProxyBodyCaptureLogger;
147
148
  logFinalRequest: (status: number, accountLabel: string, accountType: string, errorType?: string, errorMessage?: string, extra?: {
148
149
  inputTokens?: number;