@juspay/neurolink 10.4.2 → 10.4.4

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 (54) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/auth/tokenStore.d.ts +7 -0
  3. package/dist/auth/tokenStore.js +54 -0
  4. package/dist/browser/neurolink.min.js +377 -380
  5. package/dist/cli/commands/proxy.d.ts +2 -1
  6. package/dist/cli/commands/proxy.js +290 -63
  7. package/dist/cli/commands/proxyAnalyze.js +10 -1
  8. package/dist/lib/auth/tokenStore.d.ts +7 -0
  9. package/dist/lib/auth/tokenStore.js +54 -0
  10. package/dist/lib/proxy/proxyAnalysis.js +136 -12
  11. package/dist/lib/proxy/proxyTranslationEngine.d.ts +1 -0
  12. package/dist/lib/proxy/proxyTranslationEngine.js +56 -12
  13. package/dist/lib/proxy/rawStreamCapture.js +47 -1
  14. package/dist/lib/proxy/requestLogger.d.ts +2 -0
  15. package/dist/lib/proxy/requestLogger.js +72 -13
  16. package/dist/lib/proxy/rollingProxyServer.js +79 -8
  17. package/dist/lib/proxy/rollingWorkerProcess.js +20 -15
  18. package/dist/lib/proxy/rollingWorkerProtocol.js +3 -0
  19. package/dist/lib/proxy/rollingWorkerSupervisor.d.ts +1 -0
  20. package/dist/lib/proxy/rollingWorkerSupervisor.js +31 -11
  21. package/dist/lib/proxy/runtimeConfig.d.ts +1 -0
  22. package/dist/lib/proxy/runtimeConfig.js +20 -5
  23. package/dist/lib/proxy/socketWorkerRuntime.js +41 -13
  24. package/dist/lib/proxy/sseInterceptor.js +47 -1
  25. package/dist/lib/proxy/tokenRefresh.d.ts +6 -0
  26. package/dist/lib/proxy/tokenRefresh.js +70 -15
  27. package/dist/lib/proxy/usageStats.d.ts +25 -3
  28. package/dist/lib/proxy/usageStats.js +546 -55
  29. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +2 -0
  30. package/dist/lib/server/routes/claudeProxyRoutes.js +261 -297
  31. package/dist/lib/types/proxy.d.ts +90 -1
  32. package/dist/proxy/proxyAnalysis.js +136 -12
  33. package/dist/proxy/proxyTranslationEngine.d.ts +1 -0
  34. package/dist/proxy/proxyTranslationEngine.js +56 -12
  35. package/dist/proxy/rawStreamCapture.js +47 -1
  36. package/dist/proxy/requestLogger.d.ts +2 -0
  37. package/dist/proxy/requestLogger.js +72 -13
  38. package/dist/proxy/rollingProxyServer.js +79 -8
  39. package/dist/proxy/rollingWorkerProcess.js +20 -15
  40. package/dist/proxy/rollingWorkerProtocol.js +3 -0
  41. package/dist/proxy/rollingWorkerSupervisor.d.ts +1 -0
  42. package/dist/proxy/rollingWorkerSupervisor.js +31 -11
  43. package/dist/proxy/runtimeConfig.d.ts +1 -0
  44. package/dist/proxy/runtimeConfig.js +20 -5
  45. package/dist/proxy/socketWorkerRuntime.js +41 -13
  46. package/dist/proxy/sseInterceptor.js +47 -1
  47. package/dist/proxy/tokenRefresh.d.ts +6 -0
  48. package/dist/proxy/tokenRefresh.js +70 -15
  49. package/dist/proxy/usageStats.d.ts +25 -3
  50. package/dist/proxy/usageStats.js +546 -55
  51. package/dist/server/routes/claudeProxyRoutes.d.ts +2 -0
  52. package/dist/server/routes/claudeProxyRoutes.js +261 -297
  53. package/dist/types/proxy.d.ts +90 -1
  54. package/package.json +1 -1
@@ -9,6 +9,7 @@ import { randomUUID } from "node:crypto";
9
9
  import { mkdir, open, readFile, readdir, rename, rm, stat, } from "node:fs/promises";
10
10
  import { basename, dirname, join } from "node:path";
11
11
  import { AsyncMutex } from "../utils/asyncMutex.js";
12
+ import { redactUrlsInText, sanitizeForLog } from "../utils/logSanitize.js";
12
13
  import { writeJsonSnapshotAtomically } from "./snapshotPersistence.js";
13
14
  const SNAPSHOT_SCHEMA_VERSION = 1;
14
15
  const DEFAULT_FLUSH_INTERVAL_MS = 1_000;
@@ -16,6 +17,25 @@ const DEFAULT_LOCK_TIMEOUT_MS = 2_000;
16
17
  const DEFAULT_STALE_LOCK_MS = 30_000;
17
18
  const LOCK_RETRY_MS = 25;
18
19
  const MAX_CORRUPT_SNAPSHOTS = 3;
20
+ const MAX_RECENT_TERMINAL_ERRORS = 20;
21
+ const MAX_TERMINAL_ERROR_FIELD_LENGTH = 128;
22
+ const MAX_TERMINAL_ERROR_MESSAGE_LENGTH = 500;
23
+ const TERMINAL_ERROR_ESCAPE = String.fromCharCode(27);
24
+ const TERMINAL_ERROR_BELL = String.fromCharCode(7);
25
+ const TERMINAL_ERROR_OSC_PATTERN = new RegExp(`${TERMINAL_ERROR_ESCAPE}\\][\\s\\S]*?(?:${TERMINAL_ERROR_BELL}|${TERMINAL_ERROR_ESCAPE}\\\\)`, "g");
26
+ const TERMINAL_ERROR_ANSI_PATTERN = new RegExp(`${TERMINAL_ERROR_ESCAPE}\\[[0-?]*[ -/]*[@-~]`, "g");
27
+ const TERMINAL_ERROR_CATEGORIES = [
28
+ "authentication",
29
+ "client_cancelled",
30
+ "fallback_exhausted",
31
+ "invalid_request",
32
+ "proxy_error",
33
+ "rate_limit",
34
+ "stream_error",
35
+ "unclassified",
36
+ "upstream_error",
37
+ "other",
38
+ ];
19
39
  class InvalidProxyStatsSnapshotError extends Error {
20
40
  }
21
41
  function emptyStats(startedAt) {
@@ -32,6 +52,139 @@ function emptyStats(startedAt) {
32
52
  accounts: {},
33
53
  };
34
54
  }
55
+ function emptyTerminalErrorCounts() {
56
+ return Object.fromEntries(TERMINAL_ERROR_CATEGORIES.map((category) => [category, 0]));
57
+ }
58
+ function emptyTerminalErrorJournal(startedAt) {
59
+ return {
60
+ startedAt,
61
+ totalErrors: 0,
62
+ counts: emptyTerminalErrorCounts(),
63
+ recent: [],
64
+ };
65
+ }
66
+ function cloneTerminalErrorSummary(summary) {
67
+ return { ...summary };
68
+ }
69
+ function cloneTerminalErrorJournal(journal) {
70
+ return {
71
+ ...journal,
72
+ counts: { ...journal.counts },
73
+ recent: journal.recent.map(cloneTerminalErrorSummary),
74
+ };
75
+ }
76
+ function mergeTerminalErrorJournals(left, right) {
77
+ const counts = emptyTerminalErrorCounts();
78
+ for (const category of TERMINAL_ERROR_CATEGORIES) {
79
+ counts[category] = left.counts[category] + right.counts[category];
80
+ }
81
+ const byId = new Map();
82
+ for (const summary of [...left.recent, ...right.recent]) {
83
+ byId.set(summary.id, cloneTerminalErrorSummary(summary));
84
+ }
85
+ const recent = [...byId.values()]
86
+ .sort((a, b) => a.at - b.at || a.id.localeCompare(b.id))
87
+ .slice(-MAX_RECENT_TERMINAL_ERRORS);
88
+ return {
89
+ startedAt: Math.min(left.startedAt, right.startedAt),
90
+ totalErrors: left.totalErrors + right.totalErrors,
91
+ counts,
92
+ recent,
93
+ };
94
+ }
95
+ function terminalErrorsPathForStats(filePath) {
96
+ return join(dirname(filePath), "proxy-terminal-errors.json");
97
+ }
98
+ function clipTerminalErrorField(value) {
99
+ const clipped = value?.slice(0, MAX_TERMINAL_ERROR_FIELD_LENGTH);
100
+ return clipped ? clipped : undefined;
101
+ }
102
+ function stripTerminalErrorControlCharacters(value) {
103
+ const withoutAnsi = value
104
+ .replace(TERMINAL_ERROR_OSC_PATTERN, "")
105
+ .replace(TERMINAL_ERROR_ANSI_PATTERN, "");
106
+ return Array.from(withoutAnsi, (character) => {
107
+ const code = character.charCodeAt(0);
108
+ return code < 32 || (code >= 127 && code <= 159) ? " " : character;
109
+ }).join("");
110
+ }
111
+ function terminalErrorCategory(status, errorType) {
112
+ const normalized = errorType?.toLowerCase() ?? "";
113
+ if (status === 499 || normalized.includes("client_cancel")) {
114
+ return "client_cancelled";
115
+ }
116
+ if (normalized.includes("stream_telemetry")) {
117
+ return "proxy_error";
118
+ }
119
+ if (normalized.includes("stream")) {
120
+ return "stream_error";
121
+ }
122
+ if (status === 429 || normalized.includes("rate_limit")) {
123
+ return "rate_limit";
124
+ }
125
+ if (normalized.includes("auth") || normalized.includes("token_refresh")) {
126
+ return "authentication";
127
+ }
128
+ if (status === 404 ||
129
+ normalized.includes("invalid_request") ||
130
+ normalized.includes("not_found") ||
131
+ normalized.includes("construction_rejection")) {
132
+ return "invalid_request";
133
+ }
134
+ if (normalized.includes("fallback_exhausted")) {
135
+ return "fallback_exhausted";
136
+ }
137
+ if (normalized.includes("proxy") ||
138
+ normalized.includes("handler_error") ||
139
+ normalized.includes("runtime")) {
140
+ return "proxy_error";
141
+ }
142
+ if (normalized.includes("upstream") ||
143
+ normalized.includes("network") ||
144
+ normalized.includes("transient") ||
145
+ normalized.includes("generation") ||
146
+ normalized.includes("all_accounts") ||
147
+ normalized === "api_error" ||
148
+ status >= 500) {
149
+ return "upstream_error";
150
+ }
151
+ return normalized ? "other" : "unclassified";
152
+ }
153
+ function createTerminalErrorSummary(args) {
154
+ const { now, status, accountLabel, accountType, details } = args;
155
+ const errorType = clipTerminalErrorField(details?.errorType);
156
+ const message = details?.message
157
+ ? stripTerminalErrorControlCharacters(sanitizeForLog(redactUrlsInText(details.message), MAX_TERMINAL_ERROR_MESSAGE_LENGTH + MAX_TERMINAL_ERROR_FIELD_LENGTH))
158
+ .replace(/\s+/g, " ")
159
+ .trim()
160
+ .slice(0, MAX_TERMINAL_ERROR_MESSAGE_LENGTH)
161
+ : undefined;
162
+ return {
163
+ id: randomUUID(),
164
+ at: now,
165
+ status,
166
+ category: terminalErrorCategory(status, errorType),
167
+ ...(clipTerminalErrorField(details?.requestId)
168
+ ? { requestId: clipTerminalErrorField(details?.requestId) }
169
+ : {}),
170
+ ...(clipTerminalErrorField(accountLabel)
171
+ ? { account: clipTerminalErrorField(accountLabel) }
172
+ : {}),
173
+ ...(clipTerminalErrorField(accountType)
174
+ ? { accountType: clipTerminalErrorField(accountType) }
175
+ : {}),
176
+ ...(errorType ? { errorType } : {}),
177
+ ...(clipTerminalErrorField(details?.errorCode)
178
+ ? { errorCode: clipTerminalErrorField(details?.errorCode) }
179
+ : {}),
180
+ ...(clipTerminalErrorField(details?.terminalOutcome)
181
+ ? {
182
+ terminalOutcome: clipTerminalErrorField(details?.terminalOutcome),
183
+ }
184
+ : {}),
185
+ ...(message ? { message } : {}),
186
+ };
187
+ }
35
188
  function cloneAccount(account) {
36
189
  return { ...account };
37
190
  }
@@ -131,6 +284,50 @@ function validStats(value) {
131
284
  }
132
285
  return Object.entries(candidate.accounts).every(([label, account]) => validAccountStats(account) && label === account.label);
133
286
  }
287
+ function validOptionalString(value, maxLength) {
288
+ return (value === undefined ||
289
+ (typeof value === "string" && value.length <= maxLength));
290
+ }
291
+ function validTerminalErrorSummary(value) {
292
+ if (!value || typeof value !== "object") {
293
+ return false;
294
+ }
295
+ const candidate = value;
296
+ return (typeof candidate.id === "string" &&
297
+ candidate.id.length > 0 &&
298
+ candidate.id.length <= MAX_TERMINAL_ERROR_FIELD_LENGTH &&
299
+ finiteNonNegativeInteger(candidate.at) &&
300
+ finiteNonNegativeInteger(candidate.status) &&
301
+ typeof candidate.category === "string" &&
302
+ TERMINAL_ERROR_CATEGORIES.includes(candidate.category) &&
303
+ validOptionalString(candidate.requestId, MAX_TERMINAL_ERROR_FIELD_LENGTH) &&
304
+ validOptionalString(candidate.account, MAX_TERMINAL_ERROR_FIELD_LENGTH) &&
305
+ validOptionalString(candidate.accountType, MAX_TERMINAL_ERROR_FIELD_LENGTH) &&
306
+ validOptionalString(candidate.errorType, MAX_TERMINAL_ERROR_FIELD_LENGTH) &&
307
+ validOptionalString(candidate.errorCode, MAX_TERMINAL_ERROR_FIELD_LENGTH) &&
308
+ validOptionalString(candidate.terminalOutcome, MAX_TERMINAL_ERROR_FIELD_LENGTH) &&
309
+ validOptionalString(candidate.message, MAX_TERMINAL_ERROR_MESSAGE_LENGTH));
310
+ }
311
+ function validTerminalErrorJournal(value) {
312
+ if (!value || typeof value !== "object") {
313
+ return false;
314
+ }
315
+ const candidate = value;
316
+ if (!finiteNonNegativeInteger(candidate.startedAt) ||
317
+ !finiteNonNegativeInteger(candidate.totalErrors) ||
318
+ !candidate.counts ||
319
+ typeof candidate.counts !== "object" ||
320
+ !Array.isArray(candidate.recent) ||
321
+ candidate.recent.length > MAX_RECENT_TERMINAL_ERRORS ||
322
+ !candidate.recent.every(validTerminalErrorSummary)) {
323
+ return false;
324
+ }
325
+ const countEntries = Object.entries(candidate.counts);
326
+ return (countEntries.length === TERMINAL_ERROR_CATEGORIES.length &&
327
+ countEntries.every(([category, count]) => TERMINAL_ERROR_CATEGORIES.includes(category) && finiteNonNegativeInteger(count)) &&
328
+ countEntries.reduce((total, [, count]) => total + Number(count), 0) ===
329
+ candidate.totalErrors);
330
+ }
134
331
  function validSnapshot(value) {
135
332
  if (!value || typeof value !== "object") {
136
333
  return false;
@@ -141,6 +338,16 @@ function validSnapshot(value) {
141
338
  finiteNonNegativeInteger(candidate.updatedAt) &&
142
339
  validStats(candidate.stats));
143
340
  }
341
+ function validTerminalErrorSnapshot(value) {
342
+ if (!value || typeof value !== "object") {
343
+ return false;
344
+ }
345
+ const candidate = value;
346
+ return (candidate.schemaVersion === SNAPSHOT_SCHEMA_VERSION &&
347
+ finiteNonNegativeInteger(candidate.revision) &&
348
+ finiteNonNegativeInteger(candidate.updatedAt) &&
349
+ validTerminalErrorJournal(candidate.journal));
350
+ }
144
351
  function isMissingFileError(error) {
145
352
  return error?.code === "ENOENT";
146
353
  }
@@ -244,40 +451,76 @@ export class ProxyUsageStatsStore {
244
451
  lockTimeoutMs;
245
452
  staleLockMs;
246
453
  filePath;
454
+ terminalErrorsFilePath;
247
455
  stats;
248
456
  pending;
457
+ terminalErrors;
458
+ pendingTerminalErrors;
249
459
  pendingMutations = 0;
250
460
  inFlightMutations = 0;
461
+ pendingTerminalErrorMutations = 0;
462
+ inFlightTerminalErrorMutations = 0;
251
463
  revision = 0;
464
+ terminalErrorsRevision = 0;
252
465
  flushTimer = null;
253
466
  flushMutex = new AsyncMutex();
254
467
  lastFlushedAt;
255
468
  lastReconciledAt;
256
469
  lastRecoveryAt;
257
470
  lastError;
471
+ terminalErrorsLastFlushedAt;
472
+ terminalErrorsLastRecoveryAt;
473
+ terminalErrorsLastError;
474
+ snapshotVersion = 0;
258
475
  constructor(options = {}) {
259
476
  this.now = options.now ?? Date.now;
260
477
  this.flushIntervalMs = options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
261
478
  this.lockTimeoutMs = options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
262
479
  this.staleLockMs = options.staleLockMs ?? DEFAULT_STALE_LOCK_MS;
263
480
  this.filePath = options.filePath;
481
+ this.terminalErrorsFilePath =
482
+ options.terminalErrorsFilePath ??
483
+ (options.filePath
484
+ ? terminalErrorsPathForStats(options.filePath)
485
+ : undefined);
264
486
  const startedAt = this.now();
265
487
  this.stats = emptyStats(startedAt);
266
488
  this.pending = emptyStats(startedAt);
489
+ this.terminalErrors = emptyTerminalErrorJournal(startedAt);
490
+ this.pendingTerminalErrors = emptyTerminalErrorJournal(startedAt);
267
491
  }
268
492
  async initialize(filePath = this.filePath ?? "") {
269
493
  this.cancelFlushTimer();
494
+ const configuredStatsPath = this.filePath;
495
+ const configuredTerminalErrorsPath = this.terminalErrorsFilePath;
270
496
  this.filePath = filePath || undefined;
497
+ this.terminalErrorsFilePath =
498
+ this.filePath &&
499
+ configuredStatsPath === this.filePath &&
500
+ configuredTerminalErrorsPath
501
+ ? configuredTerminalErrorsPath
502
+ : this.filePath
503
+ ? terminalErrorsPathForStats(this.filePath)
504
+ : undefined;
271
505
  const startedAt = this.now();
272
506
  this.stats = emptyStats(startedAt);
273
507
  this.pending = emptyStats(startedAt);
508
+ this.terminalErrors = emptyTerminalErrorJournal(startedAt);
509
+ this.pendingTerminalErrors = emptyTerminalErrorJournal(startedAt);
274
510
  this.pendingMutations = 0;
275
511
  this.inFlightMutations = 0;
512
+ this.pendingTerminalErrorMutations = 0;
513
+ this.inFlightTerminalErrorMutations = 0;
276
514
  this.revision = 0;
515
+ this.terminalErrorsRevision = 0;
277
516
  this.lastFlushedAt = undefined;
278
517
  this.lastReconciledAt = undefined;
279
518
  this.lastRecoveryAt = undefined;
280
519
  this.lastError = undefined;
520
+ this.terminalErrorsLastFlushedAt = undefined;
521
+ this.terminalErrorsLastRecoveryAt = undefined;
522
+ this.terminalErrorsLastError = undefined;
523
+ this.snapshotVersion = 0;
281
524
  if (!this.filePath) {
282
525
  return;
283
526
  }
@@ -308,6 +551,31 @@ export class ProxyUsageStatsStore {
308
551
  this.lastError = this.describeError(error);
309
552
  }
310
553
  }
554
+ if (!this.terminalErrorsFilePath) {
555
+ return;
556
+ }
557
+ try {
558
+ const snapshot = await this.readTerminalErrorSnapshot();
559
+ if (snapshot) {
560
+ this.applyTerminalErrorSnapshot(snapshot);
561
+ }
562
+ }
563
+ catch (error) {
564
+ if (isCorruptSnapshotError(error)) {
565
+ try {
566
+ const recoveredSnapshot = await this.recoverCorruptTerminalErrorSnapshot();
567
+ if (recoveredSnapshot) {
568
+ this.applyTerminalErrorSnapshot(recoveredSnapshot);
569
+ }
570
+ }
571
+ catch (recoveryError) {
572
+ this.terminalErrorsLastError = this.describeError(recoveryError);
573
+ }
574
+ }
575
+ else {
576
+ this.terminalErrorsLastError = this.describeError(error);
577
+ }
578
+ }
311
579
  }
312
580
  recordAttempt(accountLabel, accountType) {
313
581
  const attemptedAt = this.now();
@@ -326,10 +594,22 @@ export class ProxyUsageStatsStore {
326
594
  this.applyAttemptError(this.pending, accountLabel, accountType, status, rateLimitKind, failedAt);
327
595
  this.markMutation();
328
596
  }
329
- recordFinalError(_status, accountLabel, accountType) {
597
+ recordFinalError(status, accountLabel, accountType, details) {
330
598
  const failedAt = this.now();
599
+ const summary = createTerminalErrorSummary({
600
+ now: failedAt,
601
+ status,
602
+ accountLabel,
603
+ accountType,
604
+ details,
605
+ });
331
606
  this.applyFinalError(this.stats, accountLabel, accountType, failedAt);
332
607
  this.applyFinalError(this.pending, accountLabel, accountType, failedAt);
608
+ this.applyTerminalError(this.terminalErrors, summary);
609
+ if (this.terminalErrorsFilePath) {
610
+ this.applyTerminalError(this.pendingTerminalErrors, summary);
611
+ this.pendingTerminalErrorMutations += 1;
612
+ }
333
613
  this.markMutation();
334
614
  }
335
615
  getStats() {
@@ -339,6 +619,18 @@ export class ProxyUsageStatsStore {
339
619
  const account = this.stats.accounts[label];
340
620
  return account ? cloneAccount(account) : undefined;
341
621
  }
622
+ getTerminalErrors() {
623
+ return cloneTerminalErrorJournal(this.terminalErrors);
624
+ }
625
+ getUsageSnapshot() {
626
+ const version = this.snapshotVersion;
627
+ return {
628
+ stats: this.getStats(),
629
+ statsVersion: version,
630
+ terminalErrors: this.getTerminalErrors(),
631
+ terminalErrorsVersion: version,
632
+ };
633
+ }
342
634
  getPersistenceStatus() {
343
635
  return {
344
636
  enabled: !!this.filePath,
@@ -351,70 +643,163 @@ export class ProxyUsageStatsStore {
351
643
  lastReconciledAt: this.lastReconciledAt ?? null,
352
644
  lastRecoveryAt: this.lastRecoveryAt ?? null,
353
645
  lastError: this.lastError ?? null,
646
+ terminalErrorsFilePath: this.terminalErrorsFilePath ?? null,
647
+ terminalErrorsRevision: this.terminalErrorsRevision,
648
+ terminalErrorsPending: this.pendingTerminalErrorMutations,
649
+ terminalErrorsInFlight: this.inFlightTerminalErrorMutations,
650
+ terminalErrorsUnpersisted: this.pendingTerminalErrorMutations +
651
+ this.inFlightTerminalErrorMutations,
652
+ terminalErrorsLastFlushedAt: this.terminalErrorsLastFlushedAt ?? null,
653
+ terminalErrorsLastRecoveryAt: this.terminalErrorsLastRecoveryAt ?? null,
654
+ terminalErrorsLastError: this.terminalErrorsLastError ?? null,
354
655
  };
355
656
  }
356
657
  async flush() {
357
658
  if (!this.filePath ||
358
- (this.pendingMutations === 0 && this.inFlightMutations === 0)) {
659
+ (this.pendingMutations === 0 &&
660
+ this.inFlightMutations === 0 &&
661
+ this.pendingTerminalErrorMutations === 0 &&
662
+ this.inFlightTerminalErrorMutations === 0)) {
359
663
  return;
360
664
  }
361
665
  this.cancelFlushTimer();
362
666
  await this.flushMutex.runExclusive(async () => {
363
- if (!this.filePath || this.pendingMutations === 0) {
667
+ if (!this.filePath) {
364
668
  return;
365
669
  }
366
- const delta = this.pending;
367
- const deltaMutations = this.pendingMutations;
368
- this.pending = emptyStats(this.now());
369
- this.pendingMutations = 0;
370
- this.inFlightMutations = deltaMutations;
371
- const lockPath = `${this.filePath}.lock`;
372
- let releaseLock;
373
- try {
374
- releaseLock = await acquireFileLock(lockPath, this.lockTimeoutMs, this.staleLockMs, this.now);
375
- let existing;
376
- try {
377
- existing = await this.readSnapshot();
670
+ let statsDelta;
671
+ let terminalErrorDelta;
672
+ if (this.pendingMutations > 0) {
673
+ statsDelta = {
674
+ value: this.pending,
675
+ mutations: this.pendingMutations,
676
+ };
677
+ this.pending = emptyStats(this.now());
678
+ this.pendingMutations = 0;
679
+ this.inFlightMutations = statsDelta.mutations;
680
+ }
681
+ if (this.terminalErrorsFilePath &&
682
+ this.pendingTerminalErrorMutations > 0) {
683
+ terminalErrorDelta = {
684
+ value: this.pendingTerminalErrors,
685
+ mutations: this.pendingTerminalErrorMutations,
686
+ };
687
+ this.pendingTerminalErrors = emptyTerminalErrorJournal(this.now());
688
+ this.pendingTerminalErrorMutations = 0;
689
+ this.inFlightTerminalErrorMutations = terminalErrorDelta.mutations;
690
+ }
691
+ const countersPersisted = statsDelta
692
+ ? await this.persistStatsDelta(statsDelta.value, statsDelta.mutations)
693
+ : true;
694
+ if (terminalErrorDelta) {
695
+ if (countersPersisted) {
696
+ await this.persistTerminalErrorDelta(terminalErrorDelta.value, terminalErrorDelta.mutations);
378
697
  }
379
- catch (error) {
380
- if (!isCorruptSnapshotError(error)) {
381
- throw error;
382
- }
383
- await this.quarantineCorruptSnapshot();
384
- existing = null;
698
+ else {
699
+ this.pendingTerminalErrors = mergeTerminalErrorJournals(terminalErrorDelta.value, this.pendingTerminalErrors);
700
+ this.pendingTerminalErrorMutations += terminalErrorDelta.mutations;
701
+ this.inFlightTerminalErrorMutations = 0;
385
702
  }
386
- const persisted = existing?.stats ?? emptyStats(delta.startedAt);
387
- const merged = mergeStats(persisted, delta);
388
- const revision = (existing?.revision ?? 0) + 1;
389
- const updatedAt = this.now();
390
- await writeJsonSnapshotAtomically(this.filePath, {
391
- schemaVersion: SNAPSHOT_SCHEMA_VERSION,
392
- revision,
393
- updatedAt,
394
- stats: merged,
395
- }, 0o600);
396
- this.revision = revision;
397
- this.lastFlushedAt = updatedAt;
398
- this.lastReconciledAt = updatedAt;
399
- this.lastError = undefined;
400
- this.stats = mergeStats(merged, this.pending);
401
- this.inFlightMutations = 0;
402
703
  }
403
- catch (error) {
404
- this.pending = mergeStats(delta, this.pending);
405
- this.pendingMutations += deltaMutations;
406
- this.inFlightMutations = 0;
407
- this.lastError = this.describeError(error);
704
+ if (this.pendingMutations > 0 || this.pendingTerminalErrorMutations > 0) {
408
705
  this.scheduleFlush();
409
706
  }
410
- finally {
411
- await releaseLock?.();
412
- }
413
707
  });
414
708
  }
415
- async reconcile() {
709
+ async persistStatsDelta(delta, deltaMutations) {
416
710
  if (!this.filePath) {
417
- return this.getStats();
711
+ return false;
712
+ }
713
+ let releaseLock;
714
+ try {
715
+ releaseLock = await acquireFileLock(`${this.filePath}.lock`, this.lockTimeoutMs, this.staleLockMs, this.now);
716
+ let existing;
717
+ try {
718
+ existing = await this.readSnapshot();
719
+ }
720
+ catch (error) {
721
+ if (!isCorruptSnapshotError(error)) {
722
+ throw error;
723
+ }
724
+ await this.quarantineCorruptSnapshot();
725
+ existing = null;
726
+ }
727
+ const persisted = existing?.stats ?? emptyStats(delta.startedAt);
728
+ const merged = mergeStats(persisted, delta);
729
+ const revision = (existing?.revision ?? 0) + 1;
730
+ const updatedAt = this.now();
731
+ await writeJsonSnapshotAtomically(this.filePath, {
732
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
733
+ revision,
734
+ updatedAt,
735
+ stats: merged,
736
+ }, 0o600);
737
+ this.revision = revision;
738
+ this.lastFlushedAt = updatedAt;
739
+ this.lastReconciledAt = updatedAt;
740
+ this.lastError = undefined;
741
+ this.stats = mergeStats(merged, this.pending);
742
+ this.inFlightMutations = 0;
743
+ return true;
744
+ }
745
+ catch (error) {
746
+ this.pending = mergeStats(delta, this.pending);
747
+ this.pendingMutations += deltaMutations;
748
+ this.inFlightMutations = 0;
749
+ this.lastError = this.describeError(error);
750
+ return false;
751
+ }
752
+ finally {
753
+ await releaseLock?.();
754
+ }
755
+ }
756
+ async persistTerminalErrorDelta(delta, deltaMutations) {
757
+ if (!this.terminalErrorsFilePath) {
758
+ return;
759
+ }
760
+ let releaseLock;
761
+ try {
762
+ releaseLock = await acquireFileLock(`${this.terminalErrorsFilePath}.lock`, this.lockTimeoutMs, this.staleLockMs, this.now);
763
+ let existing;
764
+ try {
765
+ existing = await this.readTerminalErrorSnapshot();
766
+ }
767
+ catch (error) {
768
+ if (!isCorruptSnapshotError(error)) {
769
+ throw error;
770
+ }
771
+ await this.quarantineCorruptTerminalErrorSnapshot();
772
+ existing = null;
773
+ }
774
+ const persisted = existing?.journal ?? emptyTerminalErrorJournal(delta.startedAt);
775
+ const merged = mergeTerminalErrorJournals(persisted, delta);
776
+ const revision = (existing?.revision ?? 0) + 1;
777
+ const updatedAt = this.now();
778
+ await writeJsonSnapshotAtomically(this.terminalErrorsFilePath, {
779
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
780
+ revision,
781
+ updatedAt,
782
+ journal: merged,
783
+ }, 0o600);
784
+ this.terminalErrorsRevision = revision;
785
+ this.terminalErrorsLastFlushedAt = updatedAt;
786
+ this.terminalErrorsLastError = undefined;
787
+ this.terminalErrors = mergeTerminalErrorJournals(merged, this.pendingTerminalErrors);
788
+ this.inFlightTerminalErrorMutations = 0;
789
+ }
790
+ catch (error) {
791
+ this.pendingTerminalErrors = mergeTerminalErrorJournals(delta, this.pendingTerminalErrors);
792
+ this.pendingTerminalErrorMutations += deltaMutations;
793
+ this.inFlightTerminalErrorMutations = 0;
794
+ this.terminalErrorsLastError = this.describeError(error);
795
+ }
796
+ finally {
797
+ await releaseLock?.();
798
+ }
799
+ }
800
+ async reconcileUsageSnapshot() {
801
+ if (!this.filePath) {
802
+ return this.getUsageSnapshot();
418
803
  }
419
804
  return this.flushMutex.runExclusive(async () => {
420
805
  try {
@@ -430,30 +815,59 @@ export class ProxyUsageStatsStore {
430
815
  catch (error) {
431
816
  this.lastError = this.describeError(error);
432
817
  }
433
- return this.getStats();
818
+ if (this.terminalErrorsFilePath) {
819
+ try {
820
+ const snapshot = await this.readTerminalErrorSnapshot();
821
+ if (snapshot) {
822
+ this.terminalErrors = mergeTerminalErrorJournals(snapshot.journal, this.pendingTerminalErrors);
823
+ this.terminalErrorsRevision = snapshot.revision;
824
+ this.terminalErrorsLastFlushedAt = snapshot.updatedAt;
825
+ }
826
+ this.terminalErrorsLastError = undefined;
827
+ }
828
+ catch (error) {
829
+ this.terminalErrorsLastError = this.describeError(error);
830
+ }
831
+ }
832
+ this.snapshotVersion += 1;
833
+ return this.getUsageSnapshot();
434
834
  });
435
835
  }
836
+ async reconcile() {
837
+ return (await this.reconcileUsageSnapshot()).stats;
838
+ }
436
839
  resetMemory() {
437
840
  this.cancelFlushTimer();
438
841
  const startedAt = this.now();
439
842
  this.stats = emptyStats(startedAt);
440
843
  this.pending = emptyStats(startedAt);
844
+ this.terminalErrors = emptyTerminalErrorJournal(startedAt);
845
+ this.pendingTerminalErrors = emptyTerminalErrorJournal(startedAt);
441
846
  this.pendingMutations = 0;
442
847
  this.inFlightMutations = 0;
848
+ this.pendingTerminalErrorMutations = 0;
849
+ this.inFlightTerminalErrorMutations = 0;
443
850
  this.revision = 0;
851
+ this.terminalErrorsRevision = 0;
444
852
  this.lastFlushedAt = undefined;
445
853
  this.lastReconciledAt = undefined;
446
854
  this.lastRecoveryAt = undefined;
447
855
  this.lastError = undefined;
856
+ this.terminalErrorsLastFlushedAt = undefined;
857
+ this.terminalErrorsLastRecoveryAt = undefined;
858
+ this.terminalErrorsLastError = undefined;
859
+ this.snapshotVersion = 0;
448
860
  }
449
861
  async resetForTests() {
450
862
  await this.flushMutex.runExclusive(async () => {
451
863
  this.resetMemory();
452
864
  this.filePath = undefined;
865
+ this.terminalErrorsFilePath = undefined;
453
866
  });
454
867
  }
455
868
  markMutation() {
456
869
  this.pendingMutations += 1;
870
+ this.snapshotVersion += 1;
457
871
  this.scheduleFlush();
458
872
  }
459
873
  applyAttempt(target, accountLabel, accountType, attemptedAt) {
@@ -497,6 +911,14 @@ export class ProxyUsageStatsStore {
497
911
  account.lastErrorAt = failedAt;
498
912
  }
499
913
  }
914
+ applyTerminalError(target, summary) {
915
+ target.totalErrors += 1;
916
+ target.counts[summary.category] += 1;
917
+ target.recent.push(cloneTerminalErrorSummary(summary));
918
+ if (target.recent.length > MAX_RECENT_TERMINAL_ERRORS) {
919
+ target.recent.splice(0, target.recent.length - MAX_RECENT_TERMINAL_ERRORS);
920
+ }
921
+ }
500
922
  ensureAccount(target, label, type) {
501
923
  if (!target.accounts[label]) {
502
924
  target.accounts[label] = {
@@ -553,12 +975,38 @@ export class ProxyUsageStatsStore {
553
975
  }
554
976
  return parsed;
555
977
  }
978
+ async readTerminalErrorSnapshot() {
979
+ if (!this.terminalErrorsFilePath) {
980
+ return null;
981
+ }
982
+ let raw;
983
+ try {
984
+ raw = await readFile(this.terminalErrorsFilePath, "utf8");
985
+ }
986
+ catch (error) {
987
+ if (isMissingFileError(error)) {
988
+ return null;
989
+ }
990
+ throw error;
991
+ }
992
+ const parsed = JSON.parse(raw);
993
+ if (!validTerminalErrorSnapshot(parsed)) {
994
+ throw new InvalidProxyStatsSnapshotError(`Invalid proxy terminal-error snapshot ${this.terminalErrorsFilePath}`);
995
+ }
996
+ return parsed;
997
+ }
556
998
  applySnapshot(snapshot) {
557
999
  this.stats = cloneStats(snapshot.stats);
558
1000
  this.pending = emptyStats(this.now());
559
1001
  this.revision = snapshot.revision;
560
1002
  this.lastFlushedAt = snapshot.updatedAt;
561
1003
  }
1004
+ applyTerminalErrorSnapshot(snapshot) {
1005
+ this.terminalErrors = cloneTerminalErrorJournal(snapshot.journal);
1006
+ this.pendingTerminalErrors = emptyTerminalErrorJournal(this.now());
1007
+ this.terminalErrorsRevision = snapshot.revision;
1008
+ this.terminalErrorsLastFlushedAt = snapshot.updatedAt;
1009
+ }
562
1010
  async recoverCorruptSnapshot() {
563
1011
  if (!this.filePath) {
564
1012
  return null;
@@ -580,6 +1028,27 @@ export class ProxyUsageStatsStore {
580
1028
  await releaseLock();
581
1029
  }
582
1030
  }
1031
+ async recoverCorruptTerminalErrorSnapshot() {
1032
+ if (!this.terminalErrorsFilePath) {
1033
+ return null;
1034
+ }
1035
+ const releaseLock = await acquireFileLock(`${this.terminalErrorsFilePath}.lock`, this.lockTimeoutMs, this.staleLockMs, this.now);
1036
+ try {
1037
+ try {
1038
+ return await this.readTerminalErrorSnapshot();
1039
+ }
1040
+ catch (error) {
1041
+ if (!isCorruptSnapshotError(error)) {
1042
+ throw error;
1043
+ }
1044
+ await this.quarantineCorruptTerminalErrorSnapshot();
1045
+ return null;
1046
+ }
1047
+ }
1048
+ finally {
1049
+ await releaseLock();
1050
+ }
1051
+ }
583
1052
  async quarantineCorruptSnapshot() {
584
1053
  if (!this.filePath) {
585
1054
  return;
@@ -597,14 +1066,30 @@ export class ProxyUsageStatsStore {
597
1066
  }
598
1067
  this.lastRecoveryAt = recoveredAt;
599
1068
  this.lastError = undefined;
600
- await this.pruneCorruptSnapshots();
1069
+ await this.pruneCorruptSnapshots(this.filePath);
601
1070
  }
602
- async pruneCorruptSnapshots() {
603
- if (!this.filePath) {
1071
+ async quarantineCorruptTerminalErrorSnapshot() {
1072
+ if (!this.terminalErrorsFilePath) {
604
1073
  return;
605
1074
  }
606
- const directory = dirname(this.filePath);
607
- const prefix = `${basename(this.filePath)}.corrupt.`;
1075
+ const recoveredAt = this.now();
1076
+ const quarantinePath = `${this.terminalErrorsFilePath}.corrupt.${recoveredAt}.${randomUUID()}`;
1077
+ try {
1078
+ await rename(this.terminalErrorsFilePath, quarantinePath);
1079
+ }
1080
+ catch (error) {
1081
+ if (!isMissingFileError(error)) {
1082
+ throw error;
1083
+ }
1084
+ return;
1085
+ }
1086
+ this.terminalErrorsLastRecoveryAt = recoveredAt;
1087
+ this.terminalErrorsLastError = undefined;
1088
+ await this.pruneCorruptSnapshots(this.terminalErrorsFilePath);
1089
+ }
1090
+ async pruneCorruptSnapshots(filePath) {
1091
+ const directory = dirname(filePath);
1092
+ const prefix = `${basename(filePath)}.corrupt.`;
608
1093
  const quarantined = (await readdir(directory))
609
1094
  .filter((entry) => entry.startsWith(prefix))
610
1095
  .sort()
@@ -630,8 +1115,8 @@ export function recordFinalSuccess(accountLabel, accountType) {
630
1115
  export function recordAttemptError(accountLabel, accountType, status, rateLimitKind) {
631
1116
  defaultStore.recordAttemptError(accountLabel, accountType, status, rateLimitKind);
632
1117
  }
633
- export function recordFinalError(_status, accountLabel, accountType) {
634
- defaultStore.recordFinalError(_status, accountLabel, accountType);
1118
+ export function recordFinalError(status, accountLabel, accountType, details) {
1119
+ defaultStore.recordFinalError(status, accountLabel, accountType, details);
635
1120
  }
636
1121
  export function getStats() {
637
1122
  return defaultStore.getStats();
@@ -639,9 +1124,15 @@ export function getStats() {
639
1124
  export async function getReconciledStats() {
640
1125
  return defaultStore.reconcile();
641
1126
  }
1127
+ export async function getReconciledUsageSnapshot() {
1128
+ return defaultStore.reconcileUsageSnapshot();
1129
+ }
642
1130
  export function getAccountStats(label) {
643
1131
  return defaultStore.getAccountStats(label);
644
1132
  }
1133
+ export function getTerminalErrors() {
1134
+ return defaultStore.getTerminalErrors();
1135
+ }
645
1136
  export function getUsageStatsPersistenceStatus() {
646
1137
  return defaultStore.getPersistenceStatus();
647
1138
  }