@juspay/neurolink 10.12.4 → 10.12.6

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 (41) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +420 -420
  3. package/dist/cli/commands/proxy.js +13 -17
  4. package/dist/cli/commands/proxyAnalyze.js +8 -3
  5. package/dist/lib/processors/archive/ArchiveProcessor.js +13 -67
  6. package/dist/lib/processors/archive/zipEntryReader.d.ts +51 -0
  7. package/dist/lib/processors/archive/zipEntryReader.js +81 -0
  8. package/dist/lib/processors/document/OpenDocumentProcessor.js +13 -1
  9. package/dist/lib/processors/document/PptxProcessor.js +58 -12
  10. package/dist/lib/proxy/logCleanupScheduler.d.ts +12 -0
  11. package/dist/lib/proxy/logCleanupScheduler.js +74 -0
  12. package/dist/lib/proxy/logCleanupWorkerEntry.d.ts +1 -0
  13. package/dist/lib/proxy/logCleanupWorkerEntry.js +15 -0
  14. package/dist/lib/proxy/proxyAnalysis.js +6 -0
  15. package/dist/lib/proxy/requestLogger.d.ts +6 -0
  16. package/dist/lib/proxy/requestLogger.js +57 -47
  17. package/dist/lib/proxy/rollingWorkerSupervisor.d.ts +2 -0
  18. package/dist/lib/proxy/rollingWorkerSupervisor.js +40 -2
  19. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +2 -0
  20. package/dist/lib/server/routes/claudeProxyRoutes.js +33 -2
  21. package/dist/lib/types/processor.d.ts +15 -0
  22. package/dist/lib/types/proxy.d.ts +32 -1
  23. package/dist/processors/archive/ArchiveProcessor.js +13 -67
  24. package/dist/processors/archive/zipEntryReader.d.ts +51 -0
  25. package/dist/processors/archive/zipEntryReader.js +80 -0
  26. package/dist/processors/document/OpenDocumentProcessor.js +13 -1
  27. package/dist/processors/document/PptxProcessor.js +58 -12
  28. package/dist/proxy/logCleanupScheduler.d.ts +12 -0
  29. package/dist/proxy/logCleanupScheduler.js +73 -0
  30. package/dist/proxy/logCleanupWorkerEntry.d.ts +1 -0
  31. package/dist/proxy/logCleanupWorkerEntry.js +14 -0
  32. package/dist/proxy/proxyAnalysis.js +6 -0
  33. package/dist/proxy/requestLogger.d.ts +6 -0
  34. package/dist/proxy/requestLogger.js +57 -47
  35. package/dist/proxy/rollingWorkerSupervisor.d.ts +2 -0
  36. package/dist/proxy/rollingWorkerSupervisor.js +40 -2
  37. package/dist/server/routes/claudeProxyRoutes.d.ts +2 -0
  38. package/dist/server/routes/claudeProxyRoutes.js +33 -2
  39. package/dist/types/processor.d.ts +15 -0
  40. package/dist/types/proxy.d.ts +32 -1
  41. package/package.json +5 -3
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Bounded ZIP entry reading, shared by every processor that opens a ZIP.
3
+ *
4
+ * Extracted from ArchiveProcessor because the Office formats are ZIPs too:
5
+ * .pptx and .odt read their entries directly and so can be handed the same
6
+ * bomb, and need the same refusal. One implementation rather than three means
7
+ * a correction to the guard lands everywhere at once.
8
+ *
9
+ * .docx and .xlsx deliberately do NOT use this. mammoth and exceljs unzip for
10
+ * themselves and were measured refusing a 400MB bomb at 46MB and 53MB peak,
11
+ * so wrapping them in a pre-scan bought no safety and roughly tripled the cost
12
+ * of every ordinary document.
13
+ *
14
+ * @module processors/archive/zipEntryReader
15
+ */
16
+ /** ZIP compression methods this reader handles (APPNOTE 4.4.5). */
17
+ export const ZIP_METHOD_STORED = 0;
18
+ export const ZIP_METHOD_DEFLATED = 8;
19
+ /**
20
+ * Whether a zlib rejection is the output bound firing rather than bad input.
21
+ *
22
+ * `maxOutputLength` aborts an inflate the moment its output would pass the cap,
23
+ * which is the whole point — but it surfaces as a plain `RangeError`, and a
24
+ * bomb reported as "failed to decompress" reads as a corrupt upload and invites
25
+ * the user to send it again. It will fail identically every time.
26
+ *
27
+ * Keyed on `code`, not the message: the message embeds a byte count.
28
+ */
29
+ export const isDecompressionBoundExceeded = (error) => error?.code === "ERR_BUFFER_TOO_LARGE";
30
+ /**
31
+ * Read one ZIP entry's bytes without trusting the size it declares.
32
+ *
33
+ * `entry.getData()` cannot be used for this. It sizes its output buffer from
34
+ * the central-directory `size` field, which the archive author chooses, and
35
+ * adm-zip only arms its own guard when that field is positive:
36
+ *
37
+ * const option = version >= 15 && expectedLength > 0
38
+ * ? { maxOutputLength: expectedLength } : {};
39
+ *
40
+ * So an entry declaring 0 disables the bound and the caller's `size > maxSize`
41
+ * check in one move — `0 > 5MB` is false, and the inflate then runs uncapped.
42
+ * The declared size is the attack, so nothing here may depend on it: the cap
43
+ * comes from our own limit and is handed to the decoder.
44
+ *
45
+ * CRC is verified on both paths rather than dropped, so bypassing `getData()`
46
+ * does not also quietly lose its corruption check — a STORED entry is copied
47
+ * out rather than decoded, but it can be damaged just the same. It detects
48
+ * damage, not malice — the CRC field is attacker-controlled too.
49
+ */
50
+ export function readZipEntryWithinLimit(entry, maxBytes, zlibModule) {
51
+ const compressed = entry.getCompressedData();
52
+ const matchesCrc = (data) => (zlibModule.crc32(data) >>> 0) === (entry.header.crc >>> 0);
53
+ // STORED: the bytes are already the payload, so its own length is the bound.
54
+ if (entry.header.method === ZIP_METHOD_STORED) {
55
+ if (compressed.length > maxBytes) {
56
+ return { status: "too-large" };
57
+ }
58
+ return matchesCrc(compressed)
59
+ ? { status: "ok", buffer: compressed }
60
+ : { status: "corrupt" };
61
+ }
62
+ if (entry.header.method !== ZIP_METHOD_DEFLATED) {
63
+ return { status: "unsupported-method" };
64
+ }
65
+ let inflated;
66
+ try {
67
+ inflated = zlibModule.inflateRawSync(compressed, {
68
+ maxOutputLength: maxBytes,
69
+ });
70
+ }
71
+ catch (error) {
72
+ if (isDecompressionBoundExceeded(error)) {
73
+ return { status: "too-large" };
74
+ }
75
+ return { status: "corrupt" };
76
+ }
77
+ return matchesCrc(inflated)
78
+ ? { status: "ok", buffer: inflated }
79
+ : { status: "corrupt" };
80
+ }
@@ -7,7 +7,9 @@
7
7
  * @module processors/document/OpenDocumentProcessor
8
8
  */
9
9
  import { createRequire } from "node:module";
10
+ import * as zlib from "node:zlib";
10
11
  import { BaseFileProcessor } from "../base/BaseFileProcessor.js";
12
+ import { readZipEntryWithinLimit } from "../archive/zipEntryReader.js";
11
13
  import { SIZE_LIMITS } from "../config/index.js";
12
14
  const require = createRequire(import.meta.url);
13
15
  // Re-import for local use within this file
@@ -64,7 +66,17 @@ export class OpenDocumentProcessor extends BaseFileProcessor {
64
66
  // Try to get content.xml
65
67
  const contentEntry = zip.getEntry("content.xml");
66
68
  if (contentEntry) {
67
- const xmlContent = contentEntry.getData().toString("utf-8");
69
+ // Bounded rather than `getData()`: an ODF file is a ZIP, so its
70
+ // content.xml can declare any uncompressed size it likes, and
71
+ // `maxSizeMB` only ever saw the compressed archive on the way in.
72
+ const read = readZipEntryWithinLimit(contentEntry, SIZE_LIMITS.DOCUMENT_MAX_MB * 1024 * 1024, zlib);
73
+ if (read.status === "too-large") {
74
+ throw new Error(`content.xml exceeds the ${SIZE_LIMITS.DOCUMENT_MAX_MB}MB limit for OpenDocument content`);
75
+ }
76
+ if (read.status !== "ok") {
77
+ throw new Error("content.xml could not be read from the archive");
78
+ }
79
+ const xmlContent = read.buffer.toString("utf-8");
68
80
  const extracted = this.extractTextFromXml(xmlContent);
69
81
  textContent = extracted.text;
70
82
  paragraphCount = extracted.paragraphCount;
@@ -27,6 +27,47 @@
27
27
  * ```
28
28
  */
29
29
  import AdmZip from "adm-zip";
30
+ import * as zlib from "node:zlib";
31
+ import { readZipEntryWithinLimit } from "../archive/zipEntryReader.js";
32
+ import { SIZE_LIMITS } from "../config/index.js";
33
+ /**
34
+ * Total budget for everything read out of one presentation.
35
+ *
36
+ * This class is a static utility with no `BaseFileProcessor` behind it, so
37
+ * there is no `maxSizeMB` to inherit and nothing else was bounding these
38
+ * reads at all — not even a late check. A .pptx is a ZIP, so any entry in it
39
+ * can declare whatever uncompressed size its author likes.
40
+ */
41
+ const PPTX_MAX_TOTAL_BYTES = SIZE_LIMITS.DOCUMENT_MAX_MB * 1024 * 1024;
42
+ /**
43
+ * Read entries out of one presentation, refusing once the total passes budget.
44
+ *
45
+ * The budget spans the call rather than each entry: a deck of two hundred
46
+ * slides that each sit just under a per-entry limit is the obvious way around
47
+ * one. Returns null for an absent or unreadable entry, which every call site
48
+ * already treats as "no content here".
49
+ */
50
+ function createBoundedEntryReader() {
51
+ let spent = 0;
52
+ return (entry) => {
53
+ if (!entry) {
54
+ return null;
55
+ }
56
+ const remaining = PPTX_MAX_TOTAL_BYTES - spent;
57
+ if (remaining <= 0) {
58
+ throw new Error(`PPTX content exceeds the ${SIZE_LIMITS.DOCUMENT_MAX_MB}MB limit`);
59
+ }
60
+ const read = readZipEntryWithinLimit(entry, remaining, zlib);
61
+ if (read.status === "too-large") {
62
+ throw new Error(`PPTX content exceeds the ${SIZE_LIMITS.DOCUMENT_MAX_MB}MB limit`);
63
+ }
64
+ if (read.status !== "ok") {
65
+ return null;
66
+ }
67
+ spent += read.buffer.length;
68
+ return read.buffer.toString("utf-8");
69
+ };
70
+ }
30
71
  /**
31
72
  * Regex to match text content within PowerPoint XML `<a:t>` elements.
32
73
  * These elements contain the actual visible text on slides.
@@ -63,14 +104,17 @@ export class PptxProcessor {
63
104
  static async extractText(content) {
64
105
  const zip = new AdmZip(content);
65
106
  const entries = zip.getEntries();
107
+ const readEntry = createBoundedEntryReader();
66
108
  // Collect slide entries with their slide numbers for sorting
67
109
  const slides = [];
68
110
  for (const entry of entries) {
69
111
  const match = entry.entryName.match(SLIDE_ENTRY_REGEX);
70
112
  if (match) {
71
113
  const slideNumber = parseInt(match[1], 10);
72
- const xmlContent = entry.getData().toString("utf-8");
73
- slides.push({ slideNumber, xml: xmlContent });
114
+ const xmlContent = readEntry(entry);
115
+ if (xmlContent !== null) {
116
+ slides.push({ slideNumber, xml: xmlContent });
117
+ }
74
118
  }
75
119
  }
76
120
  // Sort slides by number (slide1, slide2, ...)
@@ -82,7 +126,7 @@ export class PptxProcessor {
82
126
  parts.push(`Presentation: ${slides.length} slide(s)\n`);
83
127
  for (const slide of slides) {
84
128
  const texts = PptxProcessor.extractTextFromXml(slide.xml);
85
- const notes = PptxProcessor.extractNotesForSlide(zip, slide.slideNumber);
129
+ const notes = PptxProcessor.extractNotesForSlide(zip, slide.slideNumber, readEntry);
86
130
  // Emit a slide section when it has either body text or speaker notes.
87
131
  if (texts.length > 0 || notes) {
88
132
  parts.push(`### Slide ${slide.slideNumber}`);
@@ -111,12 +155,11 @@ export class PptxProcessor {
111
155
  * @param slideNumber - 1-indexed slide number
112
156
  * @returns The notes text (runs joined by a space), or null when absent
113
157
  */
114
- static extractNotesForSlide(zip, slideNumber) {
115
- const relsEntry = zip.getEntry(SLIDE_RELS_NAME(slideNumber));
116
- if (!relsEntry) {
158
+ static extractNotesForSlide(zip, slideNumber, readEntry) {
159
+ const relsXml = readEntry(zip.getEntry(SLIDE_RELS_NAME(slideNumber)));
160
+ if (relsXml === null) {
117
161
  return null;
118
162
  }
119
- const relsXml = relsEntry.getData().toString("utf-8");
120
163
  let notesTarget = null;
121
164
  RELATIONSHIP_TAG_REGEX.lastIndex = 0;
122
165
  for (let match = RELATIONSHIP_TAG_REGEX.exec(relsXml); match !== null; match = RELATIONSHIP_TAG_REGEX.exec(relsXml)) {
@@ -134,11 +177,11 @@ export class PptxProcessor {
134
177
  const normalized = notesTarget
135
178
  .replace(/^\.\.\//, "ppt/")
136
179
  .replace(/^\/+/, "");
137
- const notesEntry = zip.getEntry(normalized);
138
- if (!notesEntry) {
180
+ const notesXml = readEntry(zip.getEntry(normalized));
181
+ if (notesXml === null) {
139
182
  return null;
140
183
  }
141
- const notes = PptxProcessor.extractTextFromXml(notesEntry.getData().toString("utf-8")).join(" ");
184
+ const notes = PptxProcessor.extractTextFromXml(notesXml).join(" ");
142
185
  return notes.trim() || null;
143
186
  }
144
187
  /**
@@ -175,6 +218,7 @@ export class PptxProcessor {
175
218
  static async extractSlides(content, slideNumbers) {
176
219
  const zip = new AdmZip(content);
177
220
  const entries = zip.getEntries();
221
+ const readEntry = createBoundedEntryReader();
178
222
  // Collect all slides
179
223
  const slides = [];
180
224
  for (const entry of entries) {
@@ -182,8 +226,10 @@ export class PptxProcessor {
182
226
  if (match) {
183
227
  const slideNumber = parseInt(match[1], 10);
184
228
  if (slideNumbers.includes(slideNumber)) {
185
- const xmlContent = entry.getData().toString("utf-8");
186
- slides.push({ slideNumber, xml: xmlContent });
229
+ const xmlContent = readEntry(entry);
230
+ if (xmlContent !== null) {
231
+ slides.push({ slideNumber, xml: xmlContent });
232
+ }
187
233
  }
188
234
  }
189
235
  }
@@ -0,0 +1,12 @@
1
+ import type { ProxyLogCleanupScheduler } from "../types/index.js";
2
+ /**
3
+ * Runs retention in a worker so large log trees cannot delay readiness or
4
+ * block active streams. Concurrent runs are coalesced into the active scan.
5
+ */
6
+ export declare function startProxyLogCleanupScheduler(params: {
7
+ logsDir: string;
8
+ maxAgeDays?: number;
9
+ maxSizeMb?: number;
10
+ initialDelayMs?: number;
11
+ intervalMs?: number;
12
+ }): ProxyLogCleanupScheduler;
@@ -0,0 +1,73 @@
1
+ import { Worker } from "node:worker_threads";
2
+ import { withTimeout } from "../utils/async/withTimeout.js";
3
+ import { logger } from "../utils/logger.js";
4
+ const DEFAULT_INITIAL_DELAY_MS = 30_000;
5
+ const DEFAULT_INTERVAL_MS = 60 * 60 * 1000;
6
+ const WORKER_TERMINATION_TIMEOUT_MS = 5_000;
7
+ /**
8
+ * Runs retention in a worker so large log trees cannot delay readiness or
9
+ * block active streams. Concurrent runs are coalesced into the active scan.
10
+ */
11
+ export function startProxyLogCleanupScheduler(params) {
12
+ const maxAgeDays = params.maxAgeDays ?? 7;
13
+ const maxSizeMb = params.maxSizeMb ?? 500;
14
+ let activeWorker;
15
+ let stopped = false;
16
+ const trigger = () => {
17
+ if (stopped || activeWorker) {
18
+ return false;
19
+ }
20
+ let worker;
21
+ try {
22
+ worker = new Worker(params.workerUrl ??
23
+ new URL("./logCleanupWorkerEntry.js", import.meta.url), {
24
+ execArgv: process.execArgv.filter((argument) => !argument.startsWith("--input-type")),
25
+ workerData: {
26
+ logsDir: params.logsDir,
27
+ maxAgeDays,
28
+ maxSizeMb,
29
+ },
30
+ });
31
+ }
32
+ catch (error) {
33
+ logger.debug(`[proxy] could not start background log cleanup: ${error instanceof Error ? error.message : String(error)}`);
34
+ return false;
35
+ }
36
+ activeWorker = worker;
37
+ worker.unref();
38
+ worker.once("error", (error) => {
39
+ logger.debug(`[proxy] background log cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
40
+ });
41
+ worker.once("exit", (code) => {
42
+ if (activeWorker === worker) {
43
+ activeWorker = undefined;
44
+ }
45
+ if (code !== 0 && !stopped) {
46
+ logger.debug(`[proxy] background log cleanup exited with code ${code}`);
47
+ }
48
+ });
49
+ return true;
50
+ };
51
+ const initialTimer = setTimeout(trigger, params.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS);
52
+ initialTimer.unref();
53
+ const interval = setInterval(trigger, params.intervalMs ?? DEFAULT_INTERVAL_MS);
54
+ interval.unref();
55
+ return {
56
+ trigger,
57
+ stop: async () => {
58
+ stopped = true;
59
+ clearTimeout(initialTimer);
60
+ clearInterval(interval);
61
+ const worker = activeWorker;
62
+ activeWorker = undefined;
63
+ if (worker) {
64
+ try {
65
+ await withTimeout(worker.terminate(), WORKER_TERMINATION_TIMEOUT_MS, "Timed out terminating the proxy log cleanup worker");
66
+ }
67
+ catch (error) {
68
+ logger.debug(`[proxy] background log cleanup termination failed: ${error instanceof Error ? error.message : String(error)}`);
69
+ }
70
+ }
71
+ },
72
+ };
73
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import { parentPort, workerData } from "node:worker_threads";
2
+ import { cleanupLogsAt } from "./requestLogger.js";
3
+ const data = workerData;
4
+ try {
5
+ cleanupLogsAt(data.logsDir, data.maxAgeDays, data.maxSizeMb);
6
+ parentPort?.postMessage({ ok: true });
7
+ }
8
+ catch (error) {
9
+ parentPort?.postMessage({
10
+ ok: false,
11
+ error: error instanceof Error ? error.message : String(error),
12
+ });
13
+ process.exitCode = 1;
14
+ }
@@ -737,6 +737,10 @@ export async function analyzeProxyLogs(options) {
737
737
  const artifactsReferenced = artifactsPresent + artifactsMissing;
738
738
  const finalSummary = summarizeFinalRequests(finalRequests, terminalStreamErrors, attemptsByRequest, accounts);
739
739
  const routingSummary = summarizeRouting(finalRequests);
740
+ const streamComplete = (stream) => {
741
+ const range = observedRanges[stream];
742
+ return range.from !== null && range.from <= sinceMs;
743
+ };
740
744
  return {
741
745
  generatedAt: new Date(nowMs).toISOString(),
742
746
  since: new Date(sinceMs).toISOString(),
@@ -755,6 +759,7 @@ export async function analyzeProxyLogs(options) {
755
759
  attemptLatency: attemptLatency.length > 0,
756
760
  cacheUsage: finalSummary.cache.requestsWithUsage > 0,
757
761
  routingDecisions: routingSummary.totalRecords > 0,
762
+ comparableRequestAttempts: streamComplete("requests") && streamComplete("attempts"),
758
763
  },
759
764
  dataQuality: {
760
765
  linesRead,
@@ -768,6 +773,7 @@ export async function analyzeProxyLogs(options) {
768
773
  observedFrom: range.from === null ? null : new Date(range.from).toISOString(),
769
774
  observedTo: range.to === null ? null : new Date(range.to).toISOString(),
770
775
  startsAtOrBeforeRequestedWindow: range.from !== null && range.from <= sinceMs,
776
+ completeWindow: range.from !== null && range.from <= sinceMs,
771
777
  },
772
778
  ])),
773
779
  bodyArtifacts: {
@@ -70,3 +70,9 @@ export declare function logStreamError(entry: {
70
70
  * Non-fatal — proxy keeps working even if cleanup fails.
71
71
  */
72
72
  export declare function cleanupLogs(maxAgeDays?: number, maxSizeMb?: number): void;
73
+ /**
74
+ * Path-scoped retention implementation used by the proxy cleanup worker.
75
+ * This function is intentionally synchronous: callers must run it outside the
76
+ * request-serving process when the directory can contain many artifacts.
77
+ */
78
+ export declare function cleanupLogsAt(activeLogDir: string, maxAgeDays?: number, maxSizeMb?: number): void;
@@ -700,60 +700,70 @@ export async function logStreamError(entry) {
700
700
  * Non-fatal — proxy keeps working even if cleanup fails.
701
701
  */
702
702
  export function cleanupLogs(maxAgeDays = 7, maxSizeMb = 500) {
703
- if (!logDir || !existsSync(logDir)) {
703
+ if (!logDir) {
704
704
  return;
705
705
  }
706
706
  try {
707
- const activeLogDir = logDir;
708
- const files = collectManagedLogFiles(activeLogDir).sort((a, b) => a.mtime - b.mtime); // oldest first
709
- const currentDate = new Date().toISOString().split("T")[0];
710
- const currentMetadataLogs = new Set(["proxy", "proxy-attempts", "proxy-debug", "proxy-lifecycle"].map((prefix) => join(activeLogDir, `${prefix}-${currentDate}.jsonl`)));
711
- const canDelete = (file) => !currentMetadataLogs.has(file.path);
712
- const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
713
- let deletedCount = 0;
714
- let freedBytes = 0;
715
- // Pass 1: delete files older than maxAgeDays
716
- const remaining = [];
717
- for (const file of files) {
718
- if (file.mtime < cutoff && canDelete(file)) {
719
- unlinkSync(file.path);
720
- deletedCount++;
721
- freedBytes += file.size;
722
- }
723
- else {
724
- remaining.push(file);
725
- }
726
- }
727
- const bodiesDir = join(logDir, "bodies");
728
- if (existsSync(bodiesDir)) {
729
- pruneEmptyDirectories(bodiesDir, bodiesDir);
730
- }
731
- // Pass 2: if total size exceeds maxSizeMb, delete oldest until under limit
732
- const maxBytes = maxSizeMb * 1024 * 1024;
733
- let totalSize = remaining.reduce((sum, f) => sum + f.size, 0);
734
- const deletionCandidates = remaining.filter(canDelete);
735
- // Current-day metadata is the only reliable source for final-request,
736
- // attempt, lifecycle, and body-index reconciliation. Keep those indexes
737
- // intact during size cleanup; body artifacts and older indexes remain
738
- // eligible for eviction.
739
- while (totalSize > maxBytes && deletionCandidates.length > 0) {
740
- const oldest = deletionCandidates.shift();
741
- if (!oldest) {
742
- break;
743
- }
744
- unlinkSync(oldest.path);
745
- totalSize -= oldest.size;
707
+ cleanupLogsAt(logDir, maxAgeDays, maxSizeMb);
708
+ }
709
+ catch {
710
+ // Non-fatal for legacy in-process callers.
711
+ }
712
+ }
713
+ /**
714
+ * Path-scoped retention implementation used by the proxy cleanup worker.
715
+ * This function is intentionally synchronous: callers must run it outside the
716
+ * request-serving process when the directory can contain many artifacts.
717
+ */
718
+ export function cleanupLogsAt(activeLogDir, maxAgeDays = 7, maxSizeMb = 500) {
719
+ if (!existsSync(activeLogDir)) {
720
+ return;
721
+ }
722
+ const files = collectManagedLogFiles(activeLogDir).sort((a, b) => a.mtime - b.mtime); // oldest first
723
+ const currentDate = new Date().toISOString().split("T")[0];
724
+ const currentMetadataLogs = new Set(["proxy", "proxy-attempts", "proxy-debug", "proxy-lifecycle"].map((prefix) => join(activeLogDir, `${prefix}-${currentDate}.jsonl`)));
725
+ const canDelete = (file) => !currentMetadataLogs.has(file.path);
726
+ const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
727
+ let deletedCount = 0;
728
+ let freedBytes = 0;
729
+ // Pass 1: delete files older than maxAgeDays
730
+ const remaining = [];
731
+ for (const file of files) {
732
+ if (file.mtime < cutoff && canDelete(file)) {
733
+ unlinkSync(file.path);
746
734
  deletedCount++;
747
- freedBytes += oldest.size;
735
+ freedBytes += file.size;
748
736
  }
749
- if (existsSync(bodiesDir)) {
750
- pruneEmptyDirectories(bodiesDir, bodiesDir);
737
+ else {
738
+ remaining.push(file);
751
739
  }
752
- if (deletedCount > 0) {
753
- logger.info(`[proxy] log cleanup: deleted ${deletedCount} file(s), freed ${(freedBytes / 1024 / 1024).toFixed(1)} MB`);
740
+ }
741
+ const bodiesDir = join(activeLogDir, "bodies");
742
+ if (existsSync(bodiesDir)) {
743
+ pruneEmptyDirectories(bodiesDir, bodiesDir);
744
+ }
745
+ // Pass 2: if total size exceeds maxSizeMb, delete oldest until under limit
746
+ const maxBytes = maxSizeMb * 1024 * 1024;
747
+ let totalSize = remaining.reduce((sum, f) => sum + f.size, 0);
748
+ const deletionCandidates = remaining.filter(canDelete);
749
+ // Current-day metadata is the only reliable source for final-request,
750
+ // attempt, lifecycle, and body-index reconciliation. Keep those indexes
751
+ // intact during size cleanup; body artifacts and older indexes remain
752
+ // eligible for eviction.
753
+ while (totalSize > maxBytes && deletionCandidates.length > 0) {
754
+ const oldest = deletionCandidates.shift();
755
+ if (!oldest) {
756
+ break;
754
757
  }
758
+ unlinkSync(oldest.path);
759
+ totalSize -= oldest.size;
760
+ deletedCount++;
761
+ freedBytes += oldest.size;
755
762
  }
756
- catch {
757
- // Non-fatal
763
+ if (existsSync(bodiesDir)) {
764
+ pruneEmptyDirectories(bodiesDir, bodiesDir);
765
+ }
766
+ if (deletedCount > 0) {
767
+ logger.info(`[proxy] log cleanup: deleted ${deletedCount} file(s), freed ${(freedBytes / 1024 / 1024).toFixed(1)} MB`);
758
768
  }
759
769
  }
@@ -14,6 +14,7 @@ export declare class RollingWorkerSupervisor {
14
14
  private replacement;
15
15
  private rejectedSockets;
16
16
  private failedTransfers;
17
+ private readonly recentEvents;
17
18
  private lastFailure;
18
19
  private closed;
19
20
  private shutdownPromise;
@@ -38,5 +39,6 @@ export declare class RollingWorkerSupervisor {
38
39
  private describeTransferError;
39
40
  private extractLifecycleFailureDetails;
40
41
  private recordFailure;
42
+ private recordEvent;
41
43
  private publishState;
42
44
  }
@@ -3,6 +3,7 @@ const DEFAULT_READY_TIMEOUT_MS = 30_000;
3
3
  const DEFAULT_SOCKET_QUEUE_LIMIT = 1_024;
4
4
  const DEFAULT_SOCKET_QUEUE_TIMEOUT_MS = 30_000;
5
5
  const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;
6
+ const MAX_RECENT_SUPERVISOR_EVENTS = 100;
6
7
  /**
7
8
  * Owns worker generations while the caller owns the public listening socket.
8
9
  * Sockets are transferred once to the active worker, so response bytes never
@@ -18,6 +19,7 @@ export class RollingWorkerSupervisor {
18
19
  replacement = null;
19
20
  rejectedSockets = 0;
20
21
  failedTransfers = 0;
22
+ recentEvents = [];
21
23
  lastFailure = null;
22
24
  closed = false;
23
25
  shutdownPromise = null;
@@ -56,6 +58,7 @@ export class RollingWorkerSupervisor {
56
58
  queuedSockets: this.queuedSockets.length,
57
59
  rejectedSockets: this.rejectedSockets,
58
60
  failedTransfers: this.failedTransfers,
61
+ recentEvents: [...this.recentEvents],
59
62
  lastFailure: this.lastFailure,
60
63
  };
61
64
  }
@@ -302,6 +305,11 @@ export class RollingWorkerSupervisor {
302
305
  this.maybeDrainWorker(previous);
303
306
  }
304
307
  this.options.log?.(`[proxy-supervisor] activated generation=${generation} pid=${handle.pid} version=${expectedVersion}`);
308
+ this.recordEvent({
309
+ type: "activated",
310
+ generation,
311
+ version: expectedVersion,
312
+ });
305
313
  this.publishState();
306
314
  finish();
307
315
  });
@@ -410,6 +418,13 @@ export class RollingWorkerSupervisor {
410
418
  handleTransferFailure(worker, socket, error) {
411
419
  this.failedTransfers += 1;
412
420
  const detail = this.describeTransferError(error);
421
+ this.recordEvent({
422
+ type: "failed_transfer",
423
+ generation: worker.generation,
424
+ version: worker.version,
425
+ phase: "transfer",
426
+ reason: detail,
427
+ });
413
428
  const lifecycle = this.extractLifecycleFailureDetails(error, worker.handle.pid);
414
429
  this.recordFailure(worker.generation, worker.version, "transfer", `worker ${worker.handle.pid} failed to accept a transferred socket: ${detail}`, {
415
430
  ...lifecycle.details,
@@ -432,10 +447,16 @@ export class RollingWorkerSupervisor {
432
447
  }
433
448
  this.publishState();
434
449
  }
435
- this.rejectSocket(socket);
450
+ this.rejectSocket(socket, worker.generation, worker.version, "transfer_failure");
436
451
  }
437
- rejectSocket(socket) {
452
+ rejectSocket(socket, generation = this.active?.generation ?? null, version = this.active?.version ?? null, reason = "unavailable") {
438
453
  this.rejectedSockets += 1;
454
+ this.recordEvent({
455
+ type: "rejected_socket",
456
+ generation,
457
+ version,
458
+ reason,
459
+ });
439
460
  socket.destroy();
440
461
  this.publishState();
441
462
  }
@@ -479,6 +500,23 @@ export class RollingWorkerSupervisor {
479
500
  message: message.slice(0, 1_000),
480
501
  ...details,
481
502
  };
503
+ this.recordEvent({
504
+ type: "failure",
505
+ generation,
506
+ version,
507
+ phase,
508
+ reason: message,
509
+ });
510
+ }
511
+ recordEvent(event) {
512
+ this.recentEvents.push({
513
+ at: new Date().toISOString(),
514
+ ...event,
515
+ ...(event.reason ? { reason: event.reason.slice(0, 1_000) } : {}),
516
+ });
517
+ if (this.recentEvents.length > MAX_RECENT_SUPERVISOR_EVENTS) {
518
+ this.recentEvents.splice(0, this.recentEvents.length - MAX_RECENT_SUPERVISOR_EVENTS);
519
+ }
482
520
  }
483
521
  publishState() {
484
522
  try {
@@ -310,6 +310,7 @@ declare function shouldAttemptClaudeFallback(loopState: AnthropicLoopState): boo
310
310
  export declare function createClaudeProxyRoutes(modelRouter?: ModelRouterInterface, basePath?: string, accountStrategy?: "round-robin" | "fill-first", passthroughMode?: boolean, primaryAccountKey?: string, accountAllowlistOrRuntimeOptions?: AccountAllowlist | ClaudeProxyRouteRuntimeOptions): RouteGroup;
311
311
  declare function reconcileEligibleAccountRuntimeState(account: ProxyPassthroughAccount): void;
312
312
  export declare function getTransientSameAccountRetryDelayMs(retryNumber: number): number;
313
+ export declare function getOverloadRotationDelayMs(attemptNumber: number): number;
313
314
  declare function describeTransportError(error: unknown): string;
314
315
  /**
315
316
  * Determine whether a POST can be retried without risking duplicate provider
@@ -400,6 +401,7 @@ export declare const __testHooks: {
400
401
  describeTransportError: typeof describeTransportError;
401
402
  redactProviderErrorMessage: typeof redactProviderErrorMessage;
402
403
  isUpstreamOverload: typeof isUpstreamOverload;
404
+ getOverloadRotationDelayMs: typeof getOverloadRotationDelayMs;
403
405
  shouldAttemptClaudeFallback: typeof shouldAttemptClaudeFallback;
404
406
  executeClaudeFallbackWithRetry: typeof executeClaudeFallbackWithRetry;
405
407
  buildClaudeAnthropicFailureResponse: typeof buildClaudeAnthropicFailureResponse;