@juspay/neurolink 9.87.3 → 9.88.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/browser/neurolink.min.js +381 -381
- package/dist/cli/commands/proxy.d.ts +16 -1
- package/dist/cli/commands/proxy.js +275 -238
- package/dist/cli/factories/commandFactory.d.ts +39 -0
- package/dist/cli/factories/commandFactory.js +484 -3
- package/dist/core/conversationMemoryManager.d.ts +11 -1
- package/dist/core/conversationMemoryManager.js +44 -0
- package/dist/core/redisConversationMemoryManager.d.ts +12 -1
- package/dist/core/redisConversationMemoryManager.js +80 -0
- package/dist/lib/core/conversationMemoryManager.d.ts +11 -1
- package/dist/lib/core/conversationMemoryManager.js +44 -0
- package/dist/lib/core/redisConversationMemoryManager.d.ts +12 -1
- package/dist/lib/core/redisConversationMemoryManager.js +80 -0
- package/dist/lib/neurolink.d.ts +27 -0
- package/dist/lib/neurolink.js +133 -0
- package/dist/lib/proxy/globalInstaller.d.ts +5 -0
- package/dist/lib/proxy/globalInstaller.js +156 -0
- package/dist/lib/proxy/openaiFormat.d.ts +3 -1
- package/dist/lib/proxy/openaiFormat.js +19 -4
- package/dist/lib/proxy/proxyActivity.d.ts +8 -0
- package/dist/lib/proxy/proxyActivity.js +77 -0
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +56 -6
- package/dist/lib/server/routes/claudeProxyRoutes.js +208 -50
- package/dist/lib/server/routes/openaiProxyRoutes.js +47 -6
- package/dist/lib/types/cli.d.ts +4 -0
- package/dist/lib/types/conversation.d.ts +36 -0
- package/dist/lib/types/conversationMemoryInterface.d.ts +5 -1
- package/dist/lib/types/proxy.d.ts +49 -0
- package/dist/lib/utils/fileDetector.js +34 -10
- package/dist/neurolink.d.ts +27 -0
- package/dist/neurolink.js +133 -0
- package/dist/proxy/globalInstaller.d.ts +5 -0
- package/dist/proxy/globalInstaller.js +155 -0
- package/dist/proxy/openaiFormat.d.ts +3 -1
- package/dist/proxy/openaiFormat.js +19 -4
- package/dist/proxy/proxyActivity.d.ts +8 -0
- package/dist/proxy/proxyActivity.js +76 -0
- package/dist/server/routes/claudeProxyRoutes.d.ts +56 -6
- package/dist/server/routes/claudeProxyRoutes.js +208 -50
- package/dist/server/routes/openaiProxyRoutes.js +47 -6
- package/dist/types/cli.d.ts +4 -0
- package/dist/types/conversation.d.ts +36 -0
- package/dist/types/conversationMemoryInterface.d.ts +5 -1
- package/dist/types/proxy.d.ts +49 -0
- package/dist/utils/fileDetector.js +34 -10
- package/package.json +3 -2
|
@@ -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
|
-
/**
|
|
398
|
-
|
|
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
|
|
405
|
-
const
|
|
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 &&
|
|
452
|
+
usable: !coolingActive &&
|
|
453
|
+
weeklyStatus !== "rejected" &&
|
|
454
|
+
sessionStatus !== "rejected",
|
|
455
|
+
saturated,
|
|
408
456
|
hasQuota: !!q,
|
|
409
457
|
coolingUntil: st?.coolingUntil ?? 0,
|
|
410
|
-
|
|
458
|
+
sessionResetBucket: sessionTicking
|
|
459
|
+
? Math.floor(sessionReset / sessionResetToleranceMs)
|
|
460
|
+
: Number.POSITIVE_INFINITY,
|
|
411
461
|
sessionReset: sessionReset ?? Number.POSITIVE_INFINITY,
|
|
412
|
-
|
|
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
|
|
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.
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
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 =
|
|
435
|
-
const mb =
|
|
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
|
-
|
|
1724
|
-
const
|
|
1725
|
-
|
|
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
|
|
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
|
|
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}]: ${
|
|
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
|
-
|
|
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
|
-
|
|
1935
|
-
|
|
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
|
|
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 (
|
|
3932
|
-
return
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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",
|
package/dist/types/cli.d.ts
CHANGED
|
@@ -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 */
|
|
@@ -451,6 +451,42 @@ export type AgenticLoopReportMetadata = {
|
|
|
451
451
|
endDate: string;
|
|
452
452
|
};
|
|
453
453
|
};
|
|
454
|
+
/**
|
|
455
|
+
* Session list item for CLI/API listing
|
|
456
|
+
* Extends SessionMetadata with additional display information
|
|
457
|
+
*/
|
|
458
|
+
export type SessionListItem = SessionMetadata & {
|
|
459
|
+
/** User identifier associated with this session */
|
|
460
|
+
userId?: string;
|
|
461
|
+
/** Total number of messages in this session */
|
|
462
|
+
messageCount: number;
|
|
463
|
+
/** Human-readable time since last activity (e.g., "2 hours ago") */
|
|
464
|
+
lastActive?: string;
|
|
465
|
+
};
|
|
466
|
+
/**
|
|
467
|
+
* Complete session export format for backup/analytics
|
|
468
|
+
* Contains full session data including all messages
|
|
469
|
+
*/
|
|
470
|
+
export type SessionExport = {
|
|
471
|
+
/** Session identifier */
|
|
472
|
+
sessionId: string;
|
|
473
|
+
/** Session title/description */
|
|
474
|
+
title?: string;
|
|
475
|
+
/** User identifier */
|
|
476
|
+
userId?: string;
|
|
477
|
+
/** When session was created (ISO 8601) */
|
|
478
|
+
createdAt: string;
|
|
479
|
+
/** When session was last updated (ISO 8601) */
|
|
480
|
+
updatedAt: string;
|
|
481
|
+
/** Complete message history */
|
|
482
|
+
messages: ChatMessage[];
|
|
483
|
+
/** Export metadata */
|
|
484
|
+
exportMetadata?: {
|
|
485
|
+
exportedAt: string;
|
|
486
|
+
exportFormat: "json" | "csv";
|
|
487
|
+
neuroLinkVersion?: string;
|
|
488
|
+
};
|
|
489
|
+
};
|
|
454
490
|
/**
|
|
455
491
|
* Base conversation metadata (shared fields across all conversation types)
|
|
456
492
|
* Contains essential conversation information without heavy data arrays
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Both ConversationMemoryManager and RedisConversationMemoryManager
|
|
4
4
|
* should implement this type.
|
|
5
5
|
*/
|
|
6
|
-
import type { ChatMessage, ConversationMemoryConfig, ConversationMemoryStats, SessionMemory, StoreConversationTurnOptions } from "./conversation.js";
|
|
6
|
+
import type { ChatMessage, ConversationMemoryConfig, ConversationMemoryStats, SessionListItem, SessionMemory, StoreConversationTurnOptions } from "./conversation.js";
|
|
7
7
|
/**
|
|
8
8
|
* Common type for all conversation memory manager implementations.
|
|
9
9
|
* Provides a consistent API for storing, retrieving, and managing conversation history.
|
|
@@ -24,6 +24,10 @@ export type IConversationMemoryManager = {
|
|
|
24
24
|
clearAllSessions(): Promise<void> | void;
|
|
25
25
|
/** Get memory statistics */
|
|
26
26
|
getStats(): Promise<ConversationMemoryStats> | ConversationMemoryStats;
|
|
27
|
+
/** List all sessions with metadata (optional - for session management) */
|
|
28
|
+
listSessions?(userId?: string): Promise<SessionListItem[]>;
|
|
29
|
+
/** List all sessions with metadata (optional - for session management) */
|
|
30
|
+
listSessions?(userId?: string): Promise<SessionListItem[]>;
|
|
27
31
|
/** Get raw messages array for a session (no context filtering or summarization) */
|
|
28
32
|
getSessionMessages(sessionId: string, userId?: string): Promise<ChatMessage[]>;
|
|
29
33
|
/** Replace the entire messages array for a session */
|
package/dist/types/proxy.d.ts
CHANGED
|
@@ -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. */
|