@juspay/neurolink 9.87.3 → 9.87.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.
@@ -31,6 +31,7 @@ import { buildProxyTranslationPlan, parseRetryAfterMs, } from "../../proxy/routi
31
31
  import { writeJsonSnapshotAtomically } from "../../proxy/snapshotPersistence.js";
32
32
  import { recordAttempt, recordAttemptError, recordFinalError, recordFinalSuccess, } from "../../proxy/usageStats.js";
33
33
  import { logger } from "../../utils/logger.js";
34
+ import { withTimeout } from "../../utils/async/withTimeout.js";
34
35
  import { ProviderHealthChecker } from "../../utils/providerHealth.js";
35
36
  // ---------------------------------------------------------------------------
36
37
  // Helpers
@@ -61,6 +62,8 @@ let configuredAccountAllowlist;
61
62
  const MAX_AUTH_RETRIES = 5;
62
63
  const MAX_TRANSIENT_SAME_ACCOUNT_RETRIES = 2;
63
64
  const TRANSIENT_SAME_ACCOUNT_RETRY_DELAYS_MS = [250, 1_000];
65
+ const MAX_FALLBACK_NETWORK_RETRIES = 1;
66
+ const FALLBACK_STREAM_IDLE_TIMEOUT_MS = 2 * 60 * 1000;
64
67
  /** Maximum upstream 429 attempts per account before rotating — for a TRANSIENT
65
68
  * burst 429 only (window still "allowed", short retry-after). An exhaustion 429
66
69
  * (5h/7d window "rejected") rotates immediately with zero same-account retries,
@@ -394,45 +397,105 @@ function isQuotaRoutingEnabled() {
394
397
  const v = (process.env.NEUROLINK_PROXY_QUOTA_ROUTING ?? "").toLowerCase();
395
398
  return v !== "off" && v !== "false" && v !== "0";
396
399
  }
397
- /** Derive the ordering signals for an account from its latest runtime quota. */
398
- function accountSortMetrics(accountKey, now) {
400
+ /** Session-utilization soft limit above which an account is demoted below
401
+ * accounts with headroom (never made unusable). The utilization snapshot is
402
+ * always at least one response stale and requests land in concurrent bursts,
403
+ * so a small buffer under 100% turns the window-exhaustion handoff proactive
404
+ * (scheduled between requests) instead of reactive (a burst of 429s).
405
+ * Override with NEUROLINK_PROXY_SESSION_SOFT_LIMIT (0 < x <= 1; 1 disables). */
406
+ function getSessionSoftLimit() {
407
+ // Number() rejects partially numeric values ("0.5oops") that parseFloat
408
+ // would silently truncate.
409
+ const raw = Number(process.env.NEUROLINK_PROXY_SESSION_SOFT_LIMIT ?? "");
410
+ return Number.isFinite(raw) && raw > 0 && raw <= 1 ? raw : 0.97;
411
+ }
412
+ /** Width of the bucket within which two session resets count as "the same
413
+ * time", letting the weekly reset decide. Exact epoch equality never occurs,
414
+ * and bucketing (unlike pairwise closeness) keeps the comparator transitive.
415
+ * Override with NEUROLINK_PROXY_SESSION_RESET_TOLERANCE_MS. */
416
+ function getSessionResetToleranceMs() {
417
+ // Number() + isInteger rejects partially numeric values ("900000oops")
418
+ // that parseInt would silently truncate.
419
+ const raw = Number(process.env.NEUROLINK_PROXY_SESSION_RESET_TOLERANCE_MS ?? "");
420
+ return Number.isInteger(raw) && raw > 0 ? raw : 15 * 60 * 1000;
421
+ }
422
+ /**
423
+ * Derive the ordering signals for an account from its latest runtime quota.
424
+ *
425
+ * Reset freshening: a window whose reset epoch has already passed is treated
426
+ * as FRESH (0 used, "allowed", reset unknown) at read time — stale snapshot
427
+ * numbers from before the reset can never saturate or reject an account whose
428
+ * window Anthropic has since renewed. The snapshot itself is refreshed from
429
+ * live response headers the next time the account serves a request.
430
+ */
431
+ function accountSortMetrics(accountKey, now, sessionSoftLimit, sessionResetToleranceMs) {
399
432
  const st = accountRuntimeState.get(accountKey);
400
433
  const q = st?.quota;
401
434
  const coolingActive = !!st?.coolingUntil && now < st.coolingUntil;
435
+ // resetEpochToMs returns undefined for absent OR passed resets, so a
436
+ // ticking window is exactly "reset !== undefined".
402
437
  const weeklyReset = resetEpochToMs(q?.weeklyResetAt, now);
403
438
  const sessionReset = resetEpochToMs(q?.sessionResetAt, now);
404
- const weeklyRejected = q?.weeklyStatus === "rejected" && weeklyReset !== undefined;
405
- const sessionRejected = q?.sessionStatus === "rejected" && sessionReset !== undefined;
439
+ const sessionTicking = sessionReset !== undefined;
440
+ const weeklyTicking = weeklyReset !== undefined;
441
+ const sessionUsed = sessionTicking ? (q?.sessionUsed ?? 0) : 0;
442
+ const weeklyUsed = weeklyTicking ? (q?.weeklyUsed ?? -1) : q ? 0 : -1;
443
+ const sessionStatus = sessionTicking
444
+ ? (q?.sessionStatus ?? "unknown")
445
+ : "allowed";
446
+ const weeklyStatus = weeklyTicking
447
+ ? (q?.weeklyStatus ?? "unknown")
448
+ : "allowed";
449
+ const saturated = sessionStatus === "throttled" ||
450
+ (sessionTicking && sessionUsed >= sessionSoftLimit);
406
451
  return {
407
- usable: !coolingActive && !weeklyRejected && !sessionRejected,
452
+ usable: !coolingActive &&
453
+ weeklyStatus !== "rejected" &&
454
+ sessionStatus !== "rejected",
455
+ saturated,
408
456
  hasQuota: !!q,
409
457
  coolingUntil: st?.coolingUntil ?? 0,
410
- weeklyReset: weeklyReset ?? Number.POSITIVE_INFINITY,
458
+ sessionResetBucket: sessionTicking
459
+ ? Math.floor(sessionReset / sessionResetToleranceMs)
460
+ : Number.POSITIVE_INFINITY,
411
461
  sessionReset: sessionReset ?? Number.POSITIVE_INFINITY,
412
- weeklyUsed: q?.weeklyUsed ?? -1,
462
+ weeklyReset: weeklyReset ?? Number.POSITIVE_INFINITY,
463
+ weeklyUsed,
413
464
  };
414
465
  }
415
466
  /**
416
467
  * Order accounts to MAXIMIZE quota utilization (fill-first, smart order):
417
- * spend the account whose window refreshes SOONEST first, so its about-to-reset
468
+ * spend the window that expires SOONEST first, so its about-to-reset
418
469
  * allowance isn't wasted, then move to accounts with longer-dated resets.
419
470
  *
420
471
  * Priority among usable accounts:
421
472
  * 1. no quota data yet — probe first: one request reveals its windows and
422
473
  * self-corrects the ordering. (Ranking unknowns last would starve them
423
474
  * forever: never picked → never observed → never comparable.)
424
- * 2. soonest WEEKLY (7d) reset — the scarce, use-it-or-lose-it ceiling
425
- * 3. soonest SESSION (5h) reset
426
- * 4. highest weekly utilization finish off the one closest to done
427
- * 5. configured primary account, then insertion order
475
+ * 2. session headroom before session-saturated (>= soft limit or
476
+ * "throttled") saturated accounts then follow the same bucketed
477
+ * session ordering below: soonest back in service first, weekly
478
+ * deciding same-bucket ties.
479
+ * 3. soonest SESSION (5h) reset — the capacity expiring soonest; compared
480
+ * in tolerance buckets so near-simultaneous resets count as equal.
481
+ * 4. soonest WEEKLY (7d) reset — decides between same-bucket sessions.
482
+ * 5. highest weekly utilization — finish off the one closest to done
483
+ * 6. configured primary account, then insertion order
484
+ * An account with NO ticking session window (fresh, not yet started) has
485
+ * nothing expiring and sorts after ticking windows in step 3.
428
486
  * Cooling/rejected accounts sort last, soonest-back-to-service first, as
429
487
  * last resort.
430
488
  */
431
489
  function orderAccountsByQuota(accounts, now) {
432
490
  const primaryKey = configuredPrimaryAccountKey;
491
+ // Read the env-tunable thresholds once per sort: cheaper than per-compare,
492
+ // and a mid-sort env change can never make the comparator intransitive.
493
+ const sessionSoftLimit = getSessionSoftLimit();
494
+ const sessionResetToleranceMs = getSessionResetToleranceMs();
495
+ const metrics = (key) => accountSortMetrics(key, now, sessionSoftLimit, sessionResetToleranceMs);
433
496
  return [...accounts].sort((a, b) => {
434
- const ma = accountSortMetrics(a.key, now);
435
- const mb = accountSortMetrics(b.key, now);
497
+ const ma = metrics(a.key);
498
+ const mb = metrics(b.key);
436
499
  if (ma.usable !== mb.usable) {
437
500
  return ma.usable ? -1 : 1;
438
501
  }
@@ -444,12 +507,18 @@ function orderAccountsByQuota(accounts, now) {
444
507
  if (ma.hasQuota !== mb.hasQuota) {
445
508
  return ma.hasQuota ? 1 : -1;
446
509
  }
510
+ if (ma.saturated !== mb.saturated) {
511
+ return ma.saturated ? 1 : -1;
512
+ }
513
+ // Saturated pairs fall through to the same bucketed session ordering:
514
+ // soonest-back-in-service at bucket granularity, weekly deciding ties —
515
+ // honoring the configured tolerance instead of exact-epoch comparison.
516
+ if (ma.sessionResetBucket !== mb.sessionResetBucket) {
517
+ return ma.sessionResetBucket - mb.sessionResetBucket;
518
+ }
447
519
  if (ma.weeklyReset !== mb.weeklyReset) {
448
520
  return ma.weeklyReset - mb.weeklyReset;
449
521
  }
450
- if (ma.sessionReset !== mb.sessionReset) {
451
- return ma.sessionReset - mb.sessionReset;
452
- }
453
522
  if (ma.weeklyUsed !== mb.weeklyUsed) {
454
523
  return mb.weeklyUsed - ma.weeklyUsed;
455
524
  }
@@ -1710,25 +1779,55 @@ async function loadClaudeProxyAccounts(args) {
1710
1779
  }
1711
1780
  async function executeClaudeFallbackTranslation(args) {
1712
1781
  const { ctx, body, tracer, requestStartTime, logProxyBody, logFinalRequest, options, providerLabel, } = args;
1782
+ const fallbackAbortController = new AbortController();
1783
+ let streamResult;
1784
+ try {
1785
+ streamResult = await withTimeout(ctx.neurolink.stream({
1786
+ ...options,
1787
+ abortSignal: fallbackAbortController.signal,
1788
+ }), FALLBACK_STREAM_IDLE_TIMEOUT_MS, `Fallback ${providerLabel} initialization timed out after ${FALLBACK_STREAM_IDLE_TIMEOUT_MS}ms`);
1789
+ }
1790
+ catch (error) {
1791
+ fallbackAbortController.abort(error);
1792
+ throw error;
1793
+ }
1794
+ const consumeFallbackStream = async (onText) => {
1795
+ const iterator = streamResult.stream[Symbol.asyncIterator]();
1796
+ let collectedText = "";
1797
+ try {
1798
+ while (true) {
1799
+ const { value: chunk, done } = await withTimeout(iterator.next(), FALLBACK_STREAM_IDLE_TIMEOUT_MS, `Fallback ${providerLabel} stream timed out after ${FALLBACK_STREAM_IDLE_TIMEOUT_MS}ms of inactivity`);
1800
+ if (done) {
1801
+ return collectedText;
1802
+ }
1803
+ const text = extractText(chunk);
1804
+ if (text) {
1805
+ collectedText += text;
1806
+ onText?.(text);
1807
+ }
1808
+ }
1809
+ }
1810
+ catch (error) {
1811
+ fallbackAbortController.abort(error);
1812
+ void iterator.return?.().catch(() => {
1813
+ // Timeout/error already determines the request outcome.
1814
+ });
1815
+ throw error;
1816
+ }
1817
+ };
1713
1818
  if (body.stream) {
1714
- const streamResult = await ctx.neurolink.stream(options);
1715
1819
  const serializer = new ClaudeStreamSerializer(body.model, 0);
1716
1820
  // Eagerly consume stream so errors fire synchronously and the
1717
1821
  // fallback loop in tryConfiguredClaudeFallbackChain can catch them.
1718
1822
  const frames = [];
1719
- let collectedText = "";
1720
1823
  for (const frame of serializer.start()) {
1721
1824
  frames.push(frame);
1722
1825
  }
1723
- for await (const chunk of streamResult.stream) {
1724
- const text = extractText(chunk);
1725
- if (text) {
1726
- collectedText += text;
1727
- for (const frame of serializer.pushDelta(text)) {
1728
- frames.push(frame);
1729
- }
1826
+ const collectedText = await consumeFallbackStream((text) => {
1827
+ for (const frame of serializer.pushDelta(text)) {
1828
+ frames.push(frame);
1730
1829
  }
1731
- }
1830
+ });
1732
1831
  const toolCalls = streamResult.toolCalls ?? [];
1733
1832
  if (!hasTranslatedOutput(collectedText, toolCalls)) {
1734
1833
  throw new Error(`Translated provider ${providerLabel} returned no content or tool calls`);
@@ -1773,14 +1872,7 @@ async function executeClaudeFallbackTranslation(args) {
1773
1872
  }
1774
1873
  return sseGenerator();
1775
1874
  }
1776
- const streamResult = await ctx.neurolink.stream(options);
1777
- let collectedText = "";
1778
- for await (const chunk of streamResult.stream) {
1779
- const text = extractText(chunk);
1780
- if (text) {
1781
- collectedText += text;
1782
- }
1783
- }
1875
+ const collectedText = await consumeFallbackStream();
1784
1876
  if (!hasTranslatedOutput(collectedText, streamResult.toolCalls)) {
1785
1877
  throw new Error(`Translated provider ${providerLabel} returned no content or tool calls`);
1786
1878
  }
@@ -1813,6 +1905,25 @@ async function executeClaudeFallbackTranslation(args) {
1813
1905
  });
1814
1906
  return clientResponse;
1815
1907
  }
1908
+ async function executeClaudeFallbackWithRetry(args) {
1909
+ let lastError;
1910
+ for (let retry = 0; retry <= MAX_FALLBACK_NETWORK_RETRIES; retry += 1) {
1911
+ try {
1912
+ return await executeClaudeFallbackTranslation(args);
1913
+ }
1914
+ catch (error) {
1915
+ lastError = error;
1916
+ if (!isRetryableNetworkError(error) ||
1917
+ retry === MAX_FALLBACK_NETWORK_RETRIES) {
1918
+ throw error;
1919
+ }
1920
+ const delayMs = TRANSIENT_SAME_ACCOUNT_RETRY_DELAYS_MS[retry] ?? 250;
1921
+ logger.always(`[proxy] retrying fallback=${args.providerLabel} after transient network error (${retry + 1}/${MAX_FALLBACK_NETWORK_RETRIES}) in ${delayMs}ms: ${describeTransportError(error)}`);
1922
+ await sleep(delayMs);
1923
+ }
1924
+ }
1925
+ throw lastError;
1926
+ }
1816
1927
  async function tryConfiguredClaudeFallbackChain(args) {
1817
1928
  const { ctx, body, parsedFallbackRequest, modelRouter, tracer, requestStartTime, logProxyBody, logFinalRequest, } = args;
1818
1929
  const chain = modelRouter?.getFallbackChain() ?? [];
@@ -1830,6 +1941,7 @@ async function tryConfiguredClaudeFallbackChain(args) {
1830
1941
  attemptCount: fallbackPlan.attempts.slice(1).length,
1831
1942
  reason: "all_anthropic_accounts_exhausted",
1832
1943
  });
1944
+ let lastFallbackError;
1833
1945
  for (const fallback of fallbackPlan.attempts.slice(1)) {
1834
1946
  if (!fallback.provider || !fallback.model) {
1835
1947
  continue;
@@ -1845,7 +1957,7 @@ async function tryConfiguredClaudeFallbackChain(args) {
1845
1957
  provider: fallback.provider,
1846
1958
  model: fallback.model,
1847
1959
  });
1848
- const response = await executeClaudeFallbackTranslation({
1960
+ const response = await executeClaudeFallbackWithRetry({
1849
1961
  ctx,
1850
1962
  body,
1851
1963
  tracer,
@@ -1895,7 +2007,7 @@ async function tryConfiguredClaudeFallbackChain(args) {
1895
2007
  else if (errMsg.includes("Resource exhausted")) {
1896
2008
  errorClass = "provider_quota";
1897
2009
  }
1898
- logger.always(`[proxy] fallback ${fallback.provider}/${fallback.model} failed [${errorClass}]: ${errMsg}`);
2010
+ logger.always(`[proxy] fallback ${fallback.provider}/${fallback.model} failed [${errorClass}]: ${describeTransportError(fallbackErr)}`);
1899
2011
  recordFallbackAttempt({
1900
2012
  provider: fallback.provider,
1901
2013
  model: fallback.model,
@@ -1903,23 +2015,25 @@ async function tryConfiguredClaudeFallbackChain(args) {
1903
2015
  errorMessage: `[${errorClass}] ${errMsg}`,
1904
2016
  durationMs: Date.now() - fallbackStart,
1905
2017
  });
2018
+ lastFallbackError = `[${fallback.provider}/${fallback.model}] ${describeTransportError(fallbackErr)}`;
1906
2019
  }
1907
2020
  }
1908
- return { response: null };
2021
+ return { response: null, lastErrorMessage: lastFallbackError };
1909
2022
  }
1910
2023
  async function tryAutoClaudeFallback(args) {
1911
2024
  const { ctx, body, tracer, requestStartTime, logProxyBody, logFinalRequest } = args;
2025
+ const fallbackStart = Date.now();
1912
2026
  try {
1913
2027
  const parsed = parseClaudeRequest(body);
1914
2028
  const plan = buildProxyTranslationPlan({ provider: "anthropic", model: body.model }, [], body.model, parsed);
1915
2029
  logProxyRoutingPlan(logProxyBody, "auto_fallback", plan);
1916
2030
  const autoAttempt = plan.attempts.find((attempt) => attempt.label === "auto-provider");
1917
2031
  if (!autoAttempt) {
1918
- return null;
2032
+ return { response: null };
1919
2033
  }
1920
2034
  logger.always("[proxy] fallback → auto-provider");
1921
2035
  const options = buildProxyFallbackOptions(parsed);
1922
- return await executeClaudeFallbackTranslation({
2036
+ const response = await executeClaudeFallbackWithRetry({
1923
2037
  ctx,
1924
2038
  body,
1925
2039
  tracer,
@@ -1929,14 +2043,43 @@ async function tryAutoClaudeFallback(args) {
1929
2043
  options: options,
1930
2044
  providerLabel: "auto-provider",
1931
2045
  });
2046
+ recordFallbackAttempt({
2047
+ provider: "auto-provider",
2048
+ model: body.model,
2049
+ status: "success",
2050
+ durationMs: Date.now() - fallbackStart,
2051
+ });
2052
+ tracer?.setFallbackInfo({
2053
+ triggered: true,
2054
+ provider: "auto-provider",
2055
+ model: body.model,
2056
+ attemptCount: 1,
2057
+ reason: "fallback_success",
2058
+ });
2059
+ return { response };
1932
2060
  }
1933
2061
  catch (fallbackErr) {
1934
- logger.debug(`[proxy] fallback auto-provider failed: ${fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr)}`);
1935
- return null;
2062
+ const errorMessage = describeTransportError(fallbackErr);
2063
+ logger.always(`[proxy] fallback auto-provider failed: ${errorMessage}`);
2064
+ recordFallbackAttempt({
2065
+ provider: "auto-provider",
2066
+ model: body.model,
2067
+ status: "failure",
2068
+ errorMessage,
2069
+ durationMs: Date.now() - fallbackStart,
2070
+ });
2071
+ tracer?.setFallbackInfo({
2072
+ triggered: true,
2073
+ provider: "auto-provider",
2074
+ model: body.model,
2075
+ attemptCount: 1,
2076
+ reason: "fallback_failure",
2077
+ });
2078
+ return { response: null, lastErrorMessage: errorMessage };
1936
2079
  }
1937
2080
  }
1938
2081
  function buildClaudeAnthropicFailureResponse(args) {
1939
- const { tracer, requestStartTime, authFailureMessage, authCooldownMessage, invalidRequestFailure, sawNetworkError, sawTransientFailure, sawRateLimit, lastError, orderedAccounts, buildLoggedClaudeError, logProxyBody, logFinalRequest, } = args;
2082
+ const { tracer, requestStartTime, authFailureMessage, authCooldownMessage, invalidRequestFailure, sawNetworkError, sawTransientFailure, sawRateLimit, lastError, fallbackFailureMessage, orderedAccounts, buildLoggedClaudeError, logProxyBody, logFinalRequest, } = args;
1940
2083
  if (authFailureMessage && !sawRateLimit) {
1941
2084
  tracer?.setError("authentication_error", authFailureMessage);
1942
2085
  tracer?.end(401, Date.now() - requestStartTime);
@@ -1972,20 +2115,26 @@ function buildClaudeAnthropicFailureResponse(args) {
1972
2115
  }
1973
2116
  }
1974
2117
  if ((sawNetworkError || sawTransientFailure) && !sawRateLimit) {
2118
+ const fallbackSuffix = fallbackFailureMessage
2119
+ ? ` Fallback also failed: ${fallbackFailureMessage}`
2120
+ : "";
1975
2121
  const msg = `All Anthropic accounts failed due to transient upstream/network errors. Last error: ${lastError instanceof Error
1976
2122
  ? lastError.message
1977
- : String(lastError ?? "unknown")}`;
2123
+ : String(lastError ?? "unknown")}.${fallbackSuffix}`;
1978
2124
  tracer?.setError("transient_error", msg.slice(0, 500));
1979
2125
  tracer?.end(502, Date.now() - requestStartTime);
1980
- return buildLoggedClaudeError(502, msg);
2126
+ return buildLoggedClaudeError(502, msg, fallbackFailureMessage ? "fallback_exhausted" : "transient_error");
1981
2127
  }
1982
2128
  if (!sawRateLimit) {
2129
+ const fallbackSuffix = fallbackFailureMessage
2130
+ ? ` Fallback also failed: ${fallbackFailureMessage}`
2131
+ : "";
1983
2132
  const msg = `All Anthropic accounts failed. Last error: ${lastError instanceof Error
1984
2133
  ? lastError.message
1985
- : String(lastError ?? "unknown")}`;
2134
+ : String(lastError ?? "unknown")}.${fallbackSuffix}`;
1986
2135
  tracer?.setError("all_accounts_failed", msg.slice(0, 500));
1987
2136
  tracer?.end(502, Date.now() - requestStartTime);
1988
- return buildLoggedClaudeError(502, msg);
2137
+ return buildLoggedClaudeError(502, msg, fallbackFailureMessage ? "fallback_exhausted" : "all_accounts_failed");
1989
2138
  }
1990
2139
  const now = Date.now();
1991
2140
  const activeRateLimitCooldowns = orderedAccounts
@@ -3904,6 +4053,7 @@ async function handleAnthropicRoutedClaudeRequest(args) {
3904
4053
  // repaired by changing providers. Preserve the authoritative Anthropic 400
3905
4054
  // rather than delaying it or returning unrelated fallback output.
3906
4055
  if (shouldAttemptClaudeFallback(loopState)) {
4056
+ let fallbackFailureMessage;
3907
4057
  const configuredFallbackResult = await tryConfiguredClaudeFallbackChain({
3908
4058
  ctx,
3909
4059
  body,
@@ -3917,10 +4067,11 @@ async function handleAnthropicRoutedClaudeRequest(args) {
3917
4067
  if (configuredFallbackResult.response) {
3918
4068
  return configuredFallbackResult.response;
3919
4069
  }
4070
+ fallbackFailureMessage = configuredFallbackResult.lastErrorMessage;
3920
4071
  // Try auto-provider fallback when the configured chain didn't produce a
3921
4072
  // response (either no chain configured, or all entries failed/deduped).
3922
4073
  if (!loopState.sawRateLimit) {
3923
- const autoFallbackResponse = await tryAutoClaudeFallback({
4074
+ const autoFallbackResult = await tryAutoClaudeFallback({
3924
4075
  ctx,
3925
4076
  body,
3926
4077
  tracer,
@@ -3928,10 +4079,13 @@ async function handleAnthropicRoutedClaudeRequest(args) {
3928
4079
  logProxyBody,
3929
4080
  logFinalRequest,
3930
4081
  });
3931
- if (autoFallbackResponse) {
3932
- return autoFallbackResponse;
4082
+ if (autoFallbackResult.response) {
4083
+ return autoFallbackResult.response;
3933
4084
  }
4085
+ fallbackFailureMessage =
4086
+ autoFallbackResult.lastErrorMessage ?? fallbackFailureMessage;
3934
4087
  }
4088
+ loopState.fallbackFailureMessage = fallbackFailureMessage;
3935
4089
  }
3936
4090
  return buildClaudeAnthropicFailureResponse({
3937
4091
  tracer,
@@ -3943,6 +4097,7 @@ async function handleAnthropicRoutedClaudeRequest(args) {
3943
4097
  sawTransientFailure: loopState.sawTransientFailure,
3944
4098
  sawRateLimit: loopState.sawRateLimit,
3945
4099
  lastError: loopState.lastError,
4100
+ fallbackFailureMessage: loopState.fallbackFailureMessage,
3946
4101
  orderedAccounts,
3947
4102
  buildLoggedClaudeError,
3948
4103
  logProxyBody,
@@ -4268,6 +4423,7 @@ function isRetryableNetworkError(error) {
4268
4423
  return (normalized.includes("econnrefused") ||
4269
4424
  normalized.includes("econnreset") ||
4270
4425
  normalized.includes("etimedout") ||
4426
+ normalized.includes("timed out") ||
4271
4427
  normalized.includes("connection error") ||
4272
4428
  normalized.includes("connect error") ||
4273
4429
  normalized.includes("fetch failed") ||
@@ -4411,4 +4567,6 @@ export const __testHooks = {
4411
4567
  waitForTransientAccountAvailability,
4412
4568
  describeTransportError,
4413
4569
  shouldAttemptClaudeFallback,
4570
+ executeClaudeFallbackWithRetry,
4571
+ buildClaudeAnthropicFailureResponse,
4414
4572
  };
@@ -146,12 +146,53 @@ async function handleOpenAIToAnthropicBridge(args) {
146
146
  });
147
147
  return buildOpenAIErrorResponse(502, "Anthropic loopback returned empty stream body");
148
148
  }
149
- // Streaming success: log now since the response body is consumed by the
150
- // client. Token counts are not visible at this layer (the inner /v1/messages
151
- // handler accounts them), so we omit them here.
152
- await writeLifecycle(200);
153
- const transformed = upstream.body.pipeThrough(createClaudeToOpenAIStreamTransform(body.model));
154
- return new Response(transformed, {
149
+ let terminalStreamError;
150
+ const transformed = upstream.body.pipeThrough(createClaudeToOpenAIStreamTransform(body.model, {
151
+ onError: (message) => {
152
+ terminalStreamError = sanitizeForLog(message);
153
+ },
154
+ }));
155
+ const reader = transformed.getReader();
156
+ let lifecycleWritten = false;
157
+ const finishLifecycle = async (status, errorType, errorMessage) => {
158
+ if (lifecycleWritten) {
159
+ return;
160
+ }
161
+ lifecycleWritten = true;
162
+ await writeLifecycle(status, { errorType, errorMessage });
163
+ };
164
+ const trackedStream = new ReadableStream({
165
+ async pull(controller) {
166
+ try {
167
+ const { value, done } = await reader.read();
168
+ if (done) {
169
+ if (terminalStreamError) {
170
+ await finishLifecycle(502, "loopback_stream_error", terminalStreamError);
171
+ }
172
+ else {
173
+ await finishLifecycle(200);
174
+ }
175
+ controller.close();
176
+ return;
177
+ }
178
+ controller.enqueue(value);
179
+ }
180
+ catch (error) {
181
+ const message = sanitizeForLog(error instanceof Error ? error.message : String(error));
182
+ await finishLifecycle(502, "loopback_stream_error", message);
183
+ controller.error(error);
184
+ }
185
+ },
186
+ async cancel(reason) {
187
+ try {
188
+ await reader.cancel(reason);
189
+ }
190
+ finally {
191
+ await finishLifecycle(499, "client_cancelled", "Client cancelled the OpenAI-compatible stream");
192
+ }
193
+ },
194
+ });
195
+ return new Response(trackedStream, {
155
196
  status: 200,
156
197
  headers: {
157
198
  "content-type": "text/event-stream",
@@ -800,6 +800,8 @@ export type ProxyGuardArgs = {
800
800
  failureThreshold?: number;
801
801
  pollIntervalMs?: number;
802
802
  quiet?: boolean;
803
+ /** Run update checks only; never mutate client settings. */
804
+ updaterOnly?: boolean;
803
805
  };
804
806
  /** Arguments accepted by `neurolink proxy telemetry <subcommand>` */
805
807
  export type ProxyTelemetryArgs = {
@@ -829,6 +831,8 @@ export type ProxyState = {
829
831
  accountAllowlist?: string[];
830
832
  /** Optional fail-open guard PID that reverts Claude settings if proxy dies */
831
833
  guardPid?: number;
834
+ /** Dedicated updater PID for launchd-managed proxy installations. */
835
+ updaterPid?: number;
832
836
  /** How the proxy was launched — "launchd" if installed as service, "manual" otherwise */
833
837
  managedBy?: "launchd" | "manual";
834
838
  /** Whether the proxy is running in transparent passthrough mode */
@@ -518,6 +518,7 @@ export type AnthropicLoopState = {
518
518
  } | null;
519
519
  authFailureMessage: string | null;
520
520
  authCooldownMessage: string | null;
521
+ fallbackFailureMessage?: string;
521
522
  attemptNumber: number;
522
523
  };
523
524
  export type AnthropicUpstreamBody = {
@@ -910,6 +911,26 @@ export type QuietStatus = {
910
911
  lastActivityAt: Date | null;
911
912
  silenceDurationMs: number;
912
913
  };
914
+ /** In-process proxy request activity used to protect streaming restarts. */
915
+ export type ProxyActivitySnapshot = {
916
+ activeRequests: number;
917
+ lastActivityAt: Date | null;
918
+ };
919
+ /** Activity payload exposed by the running proxy status endpoint. */
920
+ export type ProxyRuntimeActivity = {
921
+ activeRequests: number;
922
+ lastActivityAt: string | null;
923
+ };
924
+ /** Request metadata retained by the HTTP adapter for terminal error logging. */
925
+ export type RuntimeRequestMetadata = {
926
+ requestId: string;
927
+ method: string;
928
+ path: string;
929
+ startedAt: number;
930
+ model: string;
931
+ stream: boolean;
932
+ toolCount: number;
933
+ };
913
934
  /** Accumulated upstream body capture from a raw stream. */
914
935
  export type RawStreamCapture = {
915
936
  totalBytes: number;
@@ -1073,6 +1094,34 @@ export type UpdateState = {
1073
1094
  lastUpdateAt: string | null;
1074
1095
  lastUpdateVersion: string | null;
1075
1096
  };
1097
+ /** Supported global package managers for proxy self-updates. */
1098
+ export type GlobalInstallerKind = "npm" | "pnpm";
1099
+ /** Result of probing one global package-manager executable. */
1100
+ export type GlobalInstallerProbe = {
1101
+ kind: GlobalInstallerKind;
1102
+ bin: string;
1103
+ version?: string;
1104
+ globalRoot?: string;
1105
+ globalBinDir?: string;
1106
+ working: boolean;
1107
+ installable: boolean;
1108
+ matchesCurrentInstall: boolean;
1109
+ reason?: string;
1110
+ };
1111
+ /** Selected package manager plus all candidates considered. */
1112
+ export type GlobalInstallerResolution = {
1113
+ installer?: GlobalInstallerProbe;
1114
+ tried: GlobalInstallerProbe[];
1115
+ };
1116
+ /** Injectable command runner used by global-installer tests. */
1117
+ export type GlobalInstallerExecFile = typeof import("node:child_process").execFileSync;
1118
+ /** Overrides used while resolving the global package manager. */
1119
+ export type ResolveGlobalInstallerOptions = {
1120
+ entryScript?: string;
1121
+ env?: NodeJS.ProcessEnv;
1122
+ homeDir?: string;
1123
+ execFileSync?: GlobalInstallerExecFile;
1124
+ };
1076
1125
  /** Shape of the dynamically-imported js-yaml module. `dump` is optional —
1077
1126
  * read-only consumers (proxy config loader) only need `load`; writers
1078
1127
  * (CLI primary-account commands) check `dump` before calling. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "9.87.3",
3
+ "version": "9.87.4",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (58+ servers), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
6
6
  "author": {