@juspay/neurolink 12.26.0 → 12.26.2
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 +3 -3
- package/dist/browser/neurolink.min.js +381 -381
- package/dist/mcp/externalServerManager.js +16 -2
- package/dist/mcp/mcpCircuitBreaker.d.ts +24 -3
- package/dist/mcp/mcpCircuitBreaker.js +57 -18
- package/dist/mcp/toolDiscoveryService.js +20 -4
- package/dist/server/routes/claudeProxyRoutes.js +69 -15
- package/dist/types/externalMcp.d.ts +7 -0
- package/dist/types/proxy.d.ts +9 -0
- package/docs-site/static/search-index.json +1 -1
- package/package.json +3 -2
|
@@ -1702,11 +1702,25 @@ export class ExternalServerManager extends EventEmitter {
|
|
|
1702
1702
|
instance.metrics.averageResponseTime =
|
|
1703
1703
|
totalTime / instance.metrics.totalToolCalls;
|
|
1704
1704
|
if (result.success) {
|
|
1705
|
-
|
|
1705
|
+
// A resolved `{ isError: true }` still comes back with success:true so
|
|
1706
|
+
// the caller receives the MCP error payload as-is, but it is a failed
|
|
1707
|
+
// call for telemetry: label mcp_tool_calls_total accordingly instead
|
|
1708
|
+
// of counting every resolved error as a success.
|
|
1709
|
+
const resolvedError = result.isErrorResult === true;
|
|
1710
|
+
if (resolvedError) {
|
|
1711
|
+
// Keep this instance's totalErrors (and the getStatistics() /
|
|
1712
|
+
// getExternalMCPStatistics() it feeds) consistent with the breaker,
|
|
1713
|
+
// TelemetryService and ToolDiscoveryService's failedCalls, all of
|
|
1714
|
+
// which already count a resolved isError result as a failure.
|
|
1715
|
+
instance.metrics.totalErrors++;
|
|
1716
|
+
}
|
|
1717
|
+
mcpLogger.debug(resolvedError
|
|
1718
|
+
? `[ExternalServerManager] Tool returned a resolved MCP error: ${toolName} on ${serverId}`
|
|
1719
|
+
: `[ExternalServerManager] Tool executed successfully: ${toolName} on ${serverId}`, {
|
|
1706
1720
|
duration,
|
|
1707
1721
|
});
|
|
1708
1722
|
try {
|
|
1709
|
-
TelemetryService.getInstance()?.recordMCPToolCall(toolName, duration,
|
|
1723
|
+
TelemetryService.getInstance()?.recordMCPToolCall(toolName, duration, !resolvedError);
|
|
1710
1724
|
}
|
|
1711
1725
|
catch {
|
|
1712
1726
|
/* telemetry should not break execution */
|
|
@@ -21,9 +21,30 @@ export declare class MCPCircuitBreaker extends EventEmitter {
|
|
|
21
21
|
private cleanupTimer?;
|
|
22
22
|
constructor(name: string, config?: Partial<CircuitBreakerConfig>);
|
|
23
23
|
/**
|
|
24
|
-
* Execute an operation with circuit breaker protection
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
* Execute an operation with circuit breaker protection.
|
|
25
|
+
*
|
|
26
|
+
* `operation` is handed a `recordResolvedFailure` callback so it can flag a
|
|
27
|
+
* *resolved* result as a logical failure without throwing. This matters for
|
|
28
|
+
* MCP tool calls: the client does not throw on a protocol error, it
|
|
29
|
+
* resolves with `{ isError: true, ... }`. Before this callback existed,
|
|
30
|
+
* such a call always fell through to the success path below — the breaker
|
|
31
|
+
* could never open for a tool that only ever "fails" by resolving an error
|
|
32
|
+
* result, and callers still need that resolved value returned as-is (not
|
|
33
|
+
* replaced by a thrown transport error). Call `recordResolvedFailure()`
|
|
34
|
+
* from inside `operation` when that resolved value represents a failure;
|
|
35
|
+
* the call is still counted as a normal, non-throwing success from the
|
|
36
|
+
* `Promise.race` below, so this callback is what tells `execute()`
|
|
37
|
+
* otherwise. Callers that never call it see no behavior change.
|
|
38
|
+
*/
|
|
39
|
+
execute<T>(operation: (recordResolvedFailure: (reason?: string) => void) => Promise<T>): Promise<T>;
|
|
40
|
+
/**
|
|
41
|
+
* Shared bookkeeping for a failed outcome — a thrown error, or a resolved
|
|
42
|
+
* result the caller flagged via `recordResolvedFailure`. Records the call
|
|
43
|
+
* as a failure, emits `callFailure`, and runs the state transitions that
|
|
44
|
+
* used to live inline in the `catch` block: half-open failure immediately
|
|
45
|
+
* re-opens the circuit; closed checks the failure threshold.
|
|
46
|
+
*/
|
|
47
|
+
private recordFailureOutcome;
|
|
27
48
|
/**
|
|
28
49
|
* Record a call in the history
|
|
29
50
|
*/
|
|
@@ -46,7 +46,20 @@ export class MCPCircuitBreaker extends EventEmitter {
|
|
|
46
46
|
this.cleanupTimer = setInterval(() => this.cleanupCallHistory(), 60000);
|
|
47
47
|
}
|
|
48
48
|
/**
|
|
49
|
-
* Execute an operation with circuit breaker protection
|
|
49
|
+
* Execute an operation with circuit breaker protection.
|
|
50
|
+
*
|
|
51
|
+
* `operation` is handed a `recordResolvedFailure` callback so it can flag a
|
|
52
|
+
* *resolved* result as a logical failure without throwing. This matters for
|
|
53
|
+
* MCP tool calls: the client does not throw on a protocol error, it
|
|
54
|
+
* resolves with `{ isError: true, ... }`. Before this callback existed,
|
|
55
|
+
* such a call always fell through to the success path below — the breaker
|
|
56
|
+
* could never open for a tool that only ever "fails" by resolving an error
|
|
57
|
+
* result, and callers still need that resolved value returned as-is (not
|
|
58
|
+
* replaced by a thrown transport error). Call `recordResolvedFailure()`
|
|
59
|
+
* from inside `operation` when that resolved value represents a failure;
|
|
60
|
+
* the call is still counted as a normal, non-throwing success from the
|
|
61
|
+
* `Promise.race` below, so this callback is what tells `execute()`
|
|
62
|
+
* otherwise. Callers that never call it see no behavior change.
|
|
50
63
|
*/
|
|
51
64
|
async execute(operation) {
|
|
52
65
|
const startTime = Date.now();
|
|
@@ -91,11 +104,29 @@ export class MCPCircuitBreaker extends EventEmitter {
|
|
|
91
104
|
});
|
|
92
105
|
}
|
|
93
106
|
}
|
|
107
|
+
// resolvedFailureReason is set (via recordResolvedFailure, passed into
|
|
108
|
+
// operation() below) when operation() resolves normally but represents
|
|
109
|
+
// a logical failure — e.g. an MCP tool call that resolved
|
|
110
|
+
// { isError: true } instead of throwing. It is call-scoped (a local,
|
|
111
|
+
// not instance state), so concurrent execute() calls on the same
|
|
112
|
+
// breaker (the same tool invoked in parallel) can't cross-contaminate
|
|
113
|
+
// each other's outcome.
|
|
114
|
+
let resolvedFailureReason;
|
|
115
|
+
const recordResolvedFailure = (reason) => {
|
|
116
|
+
resolvedFailureReason = reason || "Operation resolved a failure result";
|
|
117
|
+
};
|
|
94
118
|
// Execute operation with timeout
|
|
95
119
|
const result = await Promise.race([
|
|
96
|
-
operation(),
|
|
120
|
+
operation(recordResolvedFailure),
|
|
97
121
|
this.timeoutPromise(this.config.operationTimeout),
|
|
98
122
|
]);
|
|
123
|
+
if (resolvedFailureReason !== undefined) {
|
|
124
|
+
// Same bookkeeping as a thrown error below, but the resolved value
|
|
125
|
+
// is still returned to the caller unchanged — no transport error is
|
|
126
|
+
// synthesized for a result the protocol itself resolved successfully.
|
|
127
|
+
this.recordFailureOutcome(resolvedFailureReason, Date.now() - startTime);
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
99
130
|
// Record successful call
|
|
100
131
|
this.recordCall(true, Date.now() - startTime);
|
|
101
132
|
// Handle half-open success
|
|
@@ -111,26 +142,34 @@ export class MCPCircuitBreaker extends EventEmitter {
|
|
|
111
142
|
catch (error) {
|
|
112
143
|
// Record failed call
|
|
113
144
|
const duration = Date.now() - startTime;
|
|
114
|
-
this.recordCall(false, duration);
|
|
115
145
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
116
|
-
|
|
117
|
-
this.emit("callFailure", {
|
|
118
|
-
error: errorMessage,
|
|
119
|
-
duration,
|
|
120
|
-
timestamp: new Date(),
|
|
121
|
-
});
|
|
122
|
-
// Handle state transitions on failure
|
|
123
|
-
if (this.state === "half-open") {
|
|
124
|
-
// Failure in half-open immediately opens circuit
|
|
125
|
-
this.changeState("open", `Half-open test failed: ${errorMessage}`);
|
|
126
|
-
}
|
|
127
|
-
else if (this.state === "closed") {
|
|
128
|
-
// Check if we should open the circuit
|
|
129
|
-
this.checkFailureThreshold();
|
|
130
|
-
}
|
|
146
|
+
this.recordFailureOutcome(errorMessage, duration);
|
|
131
147
|
throw error;
|
|
132
148
|
}
|
|
133
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Shared bookkeeping for a failed outcome — a thrown error, or a resolved
|
|
152
|
+
* result the caller flagged via `recordResolvedFailure`. Records the call
|
|
153
|
+
* as a failure, emits `callFailure`, and runs the state transitions that
|
|
154
|
+
* used to live inline in the `catch` block: half-open failure immediately
|
|
155
|
+
* re-opens the circuit; closed checks the failure threshold.
|
|
156
|
+
*/
|
|
157
|
+
recordFailureOutcome(reason, duration) {
|
|
158
|
+
this.recordCall(false, duration);
|
|
159
|
+
this.emit("callFailure", {
|
|
160
|
+
error: reason,
|
|
161
|
+
duration,
|
|
162
|
+
timestamp: new Date(),
|
|
163
|
+
});
|
|
164
|
+
if (this.state === "half-open") {
|
|
165
|
+
// Failure in half-open immediately opens circuit
|
|
166
|
+
this.changeState("open", `Half-open test failed: ${reason}`);
|
|
167
|
+
}
|
|
168
|
+
else if (this.state === "closed") {
|
|
169
|
+
// Check if we should open the circuit
|
|
170
|
+
this.checkFailureThreshold();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
134
173
|
/**
|
|
135
174
|
* Record a call in the history
|
|
136
175
|
*/
|
|
@@ -435,8 +435,13 @@ export class ToolDiscoveryService extends EventEmitter {
|
|
|
435
435
|
resetTimeout: 30000,
|
|
436
436
|
operationTimeout: effectiveTimeout,
|
|
437
437
|
});
|
|
438
|
-
// Execute tool with circuit breaker protection
|
|
439
|
-
|
|
438
|
+
// Execute tool with circuit breaker protection. `recordResolvedFailure`
|
|
439
|
+
// lets the callback below tell the breaker that a *resolved* MCP
|
|
440
|
+
// isError result is a failure — the MCP client does not throw on a
|
|
441
|
+
// protocol error, so without this the breaker would only ever see a
|
|
442
|
+
// clean resolve and could never open for a tool that fails this way.
|
|
443
|
+
let isErrorResultDetected = false;
|
|
444
|
+
const result = await circuitBreaker.execute(async (recordResolvedFailure) => {
|
|
440
445
|
return mcpTracer.startActiveSpan("neurolink.mcp.callTool", {
|
|
441
446
|
kind: SpanKind.CLIENT,
|
|
442
447
|
attributes: {
|
|
@@ -479,6 +484,11 @@ export class ToolDiscoveryService extends EventEmitter {
|
|
|
479
484
|
code: SpanStatusCode.ERROR,
|
|
480
485
|
message: errorText || `Tool ${toolName} returned isError`,
|
|
481
486
|
});
|
|
487
|
+
// NLK: count a resolved isError result as a breaker failure
|
|
488
|
+
// and label completion telemetry as error below — without
|
|
489
|
+
// throwing, so the protocol result is still returned as-is.
|
|
490
|
+
isErrorResultDetected = true;
|
|
491
|
+
recordResolvedFailure(errorText || `Tool ${toolName} returned isError`);
|
|
482
492
|
}
|
|
483
493
|
else {
|
|
484
494
|
callSpan.setStatus({ code: SpanStatusCode.OK });
|
|
@@ -528,8 +538,13 @@ export class ToolDiscoveryService extends EventEmitter {
|
|
|
528
538
|
});
|
|
529
539
|
});
|
|
530
540
|
const duration = Date.now() - startTime;
|
|
531
|
-
// Update tool statistics
|
|
532
|
-
|
|
541
|
+
// Update tool statistics. A resolved isError result is a failed call
|
|
542
|
+
// for completion telemetry purposes even though the wrapper below
|
|
543
|
+
// still returns success:true / data:result unchanged — flipping
|
|
544
|
+
// `success` here would make externalServerManager.executeTool() throw
|
|
545
|
+
// instead of returning the resolved MCP error, which is exactly the
|
|
546
|
+
// "thrown transport error" this fix must not introduce.
|
|
547
|
+
this.updateToolStats(toolKey, !isErrorResultDetected, duration);
|
|
533
548
|
// Validate output if requested
|
|
534
549
|
if (options.validateOutput !== false) {
|
|
535
550
|
this.validateToolOutput(result);
|
|
@@ -541,6 +556,7 @@ export class ToolDiscoveryService extends EventEmitter {
|
|
|
541
556
|
return {
|
|
542
557
|
success: true,
|
|
543
558
|
data: result,
|
|
559
|
+
isErrorResult: isErrorResultDetected,
|
|
544
560
|
duration,
|
|
545
561
|
metadata: {
|
|
546
562
|
toolName,
|
|
@@ -531,6 +531,46 @@ function claimTransientRateLimitRetry(accountKey, coolingUntil, now = Date.now()
|
|
|
531
531
|
budget.retriesClaimed += 1;
|
|
532
532
|
return budget.retriesClaimed;
|
|
533
533
|
}
|
|
534
|
+
/**
|
|
535
|
+
* Claims one same-account retry after a transient rate limit and returns its
|
|
536
|
+
* delay, or undefined once this request's retries or the account's shared
|
|
537
|
+
* budget are spent. Both the HTTP 429 and the in-stream rate-limit paths use
|
|
538
|
+
* it, so a burst is paced the same way however it arrives.
|
|
539
|
+
*/
|
|
540
|
+
function claimTransientRateLimitRetryDelay(accountKey, coolingUntil, retryAfterMs, retriesThisRequest) {
|
|
541
|
+
const slot = claimTransientRateLimitRetry(accountKey, coolingUntil);
|
|
542
|
+
if (retryAfterMs === undefined ||
|
|
543
|
+
retriesThisRequest >= MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES ||
|
|
544
|
+
slot === undefined) {
|
|
545
|
+
return undefined;
|
|
546
|
+
}
|
|
547
|
+
const base = Math.min(retryAfterMs || 1_000, MAX_RATE_LIMIT_RETRY_DELAY_MS);
|
|
548
|
+
// Stagger the two shared slots, then cap after jitter so the final sleep
|
|
549
|
+
// never exceeds the configured maximum.
|
|
550
|
+
return {
|
|
551
|
+
slot,
|
|
552
|
+
delayMs: Math.min(MAX_RATE_LIMIT_RETRY_DELAY_MS, jitteredDelay(base * slot)),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* A same-account retry that was served shows the burst is over. Without this
|
|
557
|
+
* the burst cooldown outlives it, the session's next request goes to another
|
|
558
|
+
* account and loses its prompt cache — the A→B→A bounce this retry exists to
|
|
559
|
+
* prevent. Only a transient cooldown is lifted: an exhaustion cooldown that a
|
|
560
|
+
* concurrent request recorded meanwhile stays.
|
|
561
|
+
*/
|
|
562
|
+
function liftTransientCooldownAfterServedRetry(account, state) {
|
|
563
|
+
transientRateLimitRetryBudgets.delete(account.key);
|
|
564
|
+
if (state.coolingReason !== "transient" || !state.coolingUntil) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const previousCoolingUntil = state.coolingUntil;
|
|
568
|
+
state.coolingUntil = undefined;
|
|
569
|
+
state.coolingReason = undefined;
|
|
570
|
+
clearAccountCooldown(account.key, previousCoolingUntil).catch(() => {
|
|
571
|
+
// Best-effort: routing already reads the cleared in-memory state.
|
|
572
|
+
});
|
|
573
|
+
}
|
|
534
574
|
function claimTransientCooldownAdmission(accountKey, coolingUntil, now = Date.now()) {
|
|
535
575
|
if (coolingUntil <= now) {
|
|
536
576
|
return 0;
|
|
@@ -4978,8 +5018,10 @@ async function handleAnthropicStreamingSuccessResponse(args) {
|
|
|
4978
5018
|
const logicalStatus = isRateLimit ? 429 : 502;
|
|
4979
5019
|
const quota = parseQuotaHeaders(responseHeaders, { model: body.model });
|
|
4980
5020
|
const now = Date.now();
|
|
5021
|
+
let sameAccountRetry;
|
|
4981
5022
|
if (isRateLimit) {
|
|
4982
|
-
const
|
|
5023
|
+
const retryAfterMs = parseRetryAfterMs(responseHeaders["retry-after"] ?? null);
|
|
5024
|
+
const cooldownPlan = planCooldownFor429(quota, retryAfterMs, now, getUnifiedRateLimitStatus(responseHeaders), overagePolicy, typeof body.model === "string" ? body.model : undefined);
|
|
4983
5025
|
accountState.quota = quota
|
|
4984
5026
|
? mergeQuotaSnapshot(accountState.quota, quota)
|
|
4985
5027
|
: accountState.quota;
|
|
@@ -4999,6 +5041,12 @@ async function handleAnthropicStreamingSuccessResponse(args) {
|
|
|
4999
5041
|
rateLimitKind,
|
|
5000
5042
|
cooldownReason: cooldownPlan.reason,
|
|
5001
5043
|
});
|
|
5044
|
+
if (!cooldownPlan.rotateImmediately) {
|
|
5045
|
+
sameAccountRetry = {
|
|
5046
|
+
coolingUntil: cooldownPlan.coolingUntil,
|
|
5047
|
+
retryAfterMs,
|
|
5048
|
+
};
|
|
5049
|
+
}
|
|
5002
5050
|
}
|
|
5003
5051
|
else {
|
|
5004
5052
|
recordAttemptError(account.label, account.type, logicalStatus);
|
|
@@ -5006,7 +5054,7 @@ async function handleAnthropicStreamingSuccessResponse(args) {
|
|
|
5006
5054
|
retryable: true,
|
|
5007
5055
|
});
|
|
5008
5056
|
}
|
|
5009
|
-
logger.always(`[proxy] immediate SSE ${preflight.errorType} account=${account.label}: ${preflight.message}; rotating before client commit`);
|
|
5057
|
+
logger.always(`[proxy] immediate SSE ${preflight.errorType} account=${account.label}: ${preflight.message}; ${sameAccountRetry ? "transient, same account may retry" : "rotating"} before client commit`);
|
|
5010
5058
|
tracer?.recordRetry(account.label, isRateLimit
|
|
5011
5059
|
? "stream_rate_limit_before_commit"
|
|
5012
5060
|
: "stream_error_before_commit");
|
|
@@ -5035,6 +5083,7 @@ async function handleAnthropicStreamingSuccessResponse(args) {
|
|
|
5035
5083
|
...(preflight.errorType === "overloaded_error"
|
|
5036
5084
|
? { retryDelayMs: getOverloadRotationDelayMs(attemptNumber) }
|
|
5037
5085
|
: {}),
|
|
5086
|
+
...(sameAccountRetry ? { sameAccountRetry } : {}),
|
|
5038
5087
|
},
|
|
5039
5088
|
};
|
|
5040
5089
|
}
|
|
@@ -7371,21 +7420,13 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
7371
7420
|
// Transient retries are budgeted across all concurrent requests for
|
|
7372
7421
|
// this account/window. Exhaustion plans rotate immediately and never
|
|
7373
7422
|
// claim this budget.
|
|
7374
|
-
const
|
|
7375
|
-
?
|
|
7423
|
+
const retry = fetchResult.retrySameAccount
|
|
7424
|
+
? claimTransientRateLimitRetryDelay(account.key, plan.coolingUntil, fetchResult.retryAfterMs, rateLimitSameAccountRetries)
|
|
7376
7425
|
: undefined;
|
|
7377
|
-
if (
|
|
7378
|
-
fetchResult.retryAfterMs !== undefined &&
|
|
7379
|
-
rateLimitSameAccountRetries <
|
|
7380
|
-
MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES &&
|
|
7381
|
-
sharedRetrySlot !== undefined) {
|
|
7426
|
+
if (retry) {
|
|
7382
7427
|
rateLimitSameAccountRetries += 1;
|
|
7383
|
-
|
|
7384
|
-
|
|
7385
|
-
// sleep never exceeds the configured maximum.
|
|
7386
|
-
const delayMs = Math.min(MAX_RATE_LIMIT_RETRY_DELAY_MS, jitteredDelay(base * sharedRetrySlot));
|
|
7387
|
-
logger.always(`[proxy] retrying same account=${account.label} after transient 429 (shared slot ${sharedRetrySlot}/${MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES}) in ${delayMs}ms`);
|
|
7388
|
-
await sleep(delayMs);
|
|
7428
|
+
logger.always(`[proxy] retrying same account=${account.label} after transient 429 (shared slot ${retry.slot}/${MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES}) in ${retry.delayMs}ms`);
|
|
7429
|
+
await sleep(retry.delayMs);
|
|
7389
7430
|
continue;
|
|
7390
7431
|
}
|
|
7391
7432
|
// Exhaustion, or the shared transient retry budget being used up:
|
|
@@ -7560,6 +7601,16 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
7560
7601
|
loopState.sawRateLimit ||= successResult.failure.rateLimit;
|
|
7561
7602
|
loopState.sawTransientFailure ||=
|
|
7562
7603
|
!successResult.failure.rateLimit;
|
|
7604
|
+
const { sameAccountRetry } = successResult.failure;
|
|
7605
|
+
const retry = sameAccountRetry
|
|
7606
|
+
? claimTransientRateLimitRetryDelay(account.key, sameAccountRetry.coolingUntil, sameAccountRetry.retryAfterMs, rateLimitSameAccountRetries)
|
|
7607
|
+
: undefined;
|
|
7608
|
+
if (retry) {
|
|
7609
|
+
rateLimitSameAccountRetries += 1;
|
|
7610
|
+
logger.always(`[proxy] retrying same account=${account.label} after transient in-stream rate limit (shared slot ${retry.slot}/${MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES}) in ${retry.delayMs}ms`);
|
|
7611
|
+
await sleep(retry.delayMs);
|
|
7612
|
+
continue;
|
|
7613
|
+
}
|
|
7563
7614
|
if (hasNextAccount && successResult.failure.retryDelayMs) {
|
|
7564
7615
|
logger.always(`[proxy] pacing cross-account SSE overload rotation for ${successResult.failure.retryDelayMs}ms`);
|
|
7565
7616
|
await sleep(successResult.failure.retryDelayMs);
|
|
@@ -7569,6 +7620,9 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
7569
7620
|
}
|
|
7570
7621
|
admissionTransferredToStream =
|
|
7571
7622
|
successResult.holdsAccountAdmission === true;
|
|
7623
|
+
if (successResult.served && rateLimitSameAccountRetries > 0) {
|
|
7624
|
+
liftTransientCooldownAfterServedRetry(account, accountState);
|
|
7625
|
+
}
|
|
7572
7626
|
if (bindServedSession && successResult.served) {
|
|
7573
7627
|
bindServedSession(account.key);
|
|
7574
7628
|
}
|
|
@@ -234,6 +234,13 @@ export type ExternalMCPToolResult = {
|
|
|
234
234
|
data?: unknown;
|
|
235
235
|
/** Error message if failed */
|
|
236
236
|
error?: string;
|
|
237
|
+
/**
|
|
238
|
+
* True when the call completed at the transport level but the MCP result
|
|
239
|
+
* itself is `{ isError: true }`. `success` stays true for such results so
|
|
240
|
+
* the resolved MCP error payload still reaches the caller unchanged; this
|
|
241
|
+
* flag is what lets stats and telemetry count the call as a failure.
|
|
242
|
+
*/
|
|
243
|
+
isErrorResult?: boolean;
|
|
237
244
|
/** Execution duration in milliseconds */
|
|
238
245
|
duration: number;
|
|
239
246
|
/** Tool execution metadata */
|
package/dist/types/proxy.d.ts
CHANGED
|
@@ -1128,6 +1128,15 @@ export type AnthropicSuccessResult = {
|
|
|
1128
1128
|
message: string;
|
|
1129
1129
|
rateLimit: boolean;
|
|
1130
1130
|
retryDelayMs?: number;
|
|
1131
|
+
/**
|
|
1132
|
+
* Set only for a transient (burst) rate limit: the attempt loop may
|
|
1133
|
+
* retry the same account within the shared budget before rotating,
|
|
1134
|
+
* exactly as it does for an HTTP 429.
|
|
1135
|
+
*/
|
|
1136
|
+
sameAccountRetry?: {
|
|
1137
|
+
coolingUntil: number;
|
|
1138
|
+
retryAfterMs: number;
|
|
1139
|
+
};
|
|
1131
1140
|
};
|
|
1132
1141
|
} | {
|
|
1133
1142
|
response: Response | unknown;
|
|
@@ -2884,7 +2884,7 @@
|
|
|
2884
2884
|
{"objectID":"d3e03ca160a62086a328b7f6782dd27a4cc252801bd4ebbf6c6b15961a0dd314","title":"Account 3: API key for fallback","url":"/docs/features/claude-proxy#account-3-api-key-for-fallback","content":"neurolink auth login anthropic --method api-key --add --label api\n`","hierarchy":{"lvl0":"Features","lvl1":"Claude Proxy","lvl2":"Account 3: API key for fallback","lvl3":""}},
|
|
2885
2885
|
{"objectID":"2167cdac36707378e3d95b4ae7f07988af5ef02cf9d09b8809b897263316efcc","title":"How accounts are selected","url":"/docs/features/claude-proxy#how-accounts-are-selected","content":"The proxy discovers accounts in this order:\nCompound keys from TokenStore (e.g., , )\nLegacy credentials file (if no compound keys exist)\nenvironment variable (if no other accounts exist)\n\nWhen is configured, this discovery happens only within the allowed set. A disabled or unavailable TokenStore account does not cause the proxy to activate a legacy file or environment key while TokenStore entries still exist.\n\nWithin the account pool, the proxy uses fill-first routing: it always tries the first non-cooling account and only switches on failure. This avoids unnecessary identity switches that could confuse Claude Code's session state.","hierarchy":{"lvl0":"Features","lvl1":"Claude Proxy","lvl2":"How accounts are selected","lvl3":""}},
|
|
2886
2886
|
{"objectID":"8c3a17a43510fb3eeca918e6c91dd4f38f37f0392e1440e021488aefa1e56b80","title":"Model-scoped weekly limits","url":"/docs/features/claude-proxy#model-scoped-weekly-limits","content":"Some plans cap a specific model separately from the overall weekly window — a\nFable-only weekly allowance, for instance. Anthropic reports that cap as its own\nheader family (), sent only on responses\nfor the model it applies to, so the proxy learns about it from live traffic\nrather than needing a refresh.\n\nRouting treats a spent model-scoped cap as per-model, never per-account:\nAn account whose cap for the requested model is spent is skipped for that\n model only. It stays fully available for every other model, and no cooldown\n is set — cooling is account-wide and would wrongly withhold a healthy account.\nAmong accounts that do have headroom, the one closest to spending its\n allowance is preferred, so the pool finishes an allowance rather than spreading\n across all of them. This rung only applies when both candidates report a window\n for the model.\nIf every account has spent the cap, the request is not attempted. The\n client gets a naming the model, the real reset time, and that other\n models remain available — switch model, or add an account with headroom.\nA scoped window older than the quota freshness budget is ignored, so stale or\n mis-parsed data can never take the pool down. Routing falls back to attempting\n the request.\n\n shows any scoped window under its account, and\n returns the full array.","hierarchy":{"lvl0":"Features","lvl1":"Claude Proxy","lvl2":"Model-scoped weekly limits","lvl3":""}},
|
|
2887
|
-
{"objectID":"971d8917e081ed8fe1f30633edee82462617b534a0645dd9f370c37c0769494f","title":"Cooldown and backoff","url":"/docs/features/claude-proxy#cooldown-and-backoff","content":"When an account encounters an error, it enters a cooldown period based on the error type:\n\n| Failure
|
|
2887
|
+
{"objectID":"971d8917e081ed8fe1f30633edee82462617b534a0645dd9f370c37c0769494f","title":"Cooldown and backoff","url":"/docs/features/claude-proxy#cooldown-and-backoff","content":"When an account encounters an error, it enters a cooldown period based on the error type:\n\n| Failure | Cooldown | Behavior |\n| --------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------ |\n| Authoritative unified, 5-hour, or 7-day rejection | Upstream reset or , capped per reason | Persist cooldown and rotate immediately |\n| Transient burst 429, as a status or as the stream's first event | Upstream delay, capped at 15 minutes | At most 2 same-account retries, then rotate |\n| Refresh credential rejection (///) | Disabled until explicit login | Rotate without retrying an invalid refresh token |\n| Refresh network, , or | 30 seconds to 5 minutes | Persist auth cooldown and rotate |\n| Upstream or network error | Bounded same-account retries | Rotate after retry budget |\n\nCooldown updates are extend-only: a late concurrent response cannot shorten a longer known reset window.\n\nThe one exception is a transient burst. When a same-account retry is served, the\nburst is over, so its cooldown is lifted and the session's next request stays on\nthat account instead of moving away and losing its prompt cache. An exhaustion\ncooldown recorded in the meantime is left in place.\n\nEach cooldown is also capped by what its reason can mean — a cooldown\ndescribes a 5-hour window, so it can never run for days no matter what reset the\nupstream reports. The cap is applied when the cooldown is written and again when\nit is read back from disk, so an entry written by an older build heals itself on\nload and logs that it did:\n\nUse to se","hierarchy":{"lvl0":"Features","lvl1":"Claude Proxy","lvl2":"Cooldown and backoff","lvl3":""}},
|
|
2888
2888
|
{"objectID":"c95cca165ca71f8c198ba1ad405ec47ab1666e20d2d9ad4120139306226f26f0","title":"Error Handling","url":"/docs/features/claude-proxy#error-handling","content":"The proxy classifies upstream errors and applies different strategies:","hierarchy":{"lvl0":"Features","lvl1":"Claude Proxy","lvl2":"Error Handling","lvl3":""}},
|
|
2889
2889
|
{"objectID":"7a743b6bb13366c925cd238d4853826a08f0b161659bcb0575d5b2e83709a2e5","title":"429 Rate Limit","url":"/docs/features/claude-proxy#429-rate-limit","content":"Treat top-level as authoritative, even if sub-windows still say .\nPrefer the rejected 5-hour or 7-day reset and otherwise use .\nPersist the cooldown and rotate immediately for authoritative exhaustion.\nReturn the earliest recovery timestamp without another upstream request when all accounts are cooling.","hierarchy":{"lvl0":"Features","lvl1":"Claude Proxy","lvl2":"429 Rate Limit","lvl3":""}},
|
|
2890
2890
|
{"objectID":"1522c22c8467bae6a5a51e9e4c4dca5792a7b45c457b0628ef5536d068d642b3","title":"401/402/403 Authentication Errors","url":"/docs/features/claude-proxy#401402403-authentication-errors","content":"OAuth accounts with refresh token: Serialize refresh, persist the new rotating token, and retry. A rejected refresh credential disables the account until re-authentication; transient refresh infrastructure errors cool and rotate.\nOAuth accounts without refresh token: Disable until re-authentication and rotate.\nAPI key accounts: Rotate after authentication failure.","hierarchy":{"lvl0":"Features","lvl1":"Claude Proxy","lvl2":"401/402/403 Authentication Errors","lvl3":""}},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juspay/neurolink",
|
|
3
|
-
"version": "12.26.
|
|
3
|
+
"version": "12.26.2",
|
|
4
4
|
"packageManager": "pnpm@10.15.1",
|
|
5
5
|
"description": "The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002/call) for routing, tool selection and context budgeting. MCP-native (4 transports), voice TTS/STT/realtime, RAG, agents, memory, compaction, 9 observability exporters. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · TypeSafe Jev and more.",
|
|
6
6
|
"author": {
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"// CI tier — fast, no live AI calls, safe for every commit (test:unit; also see the separate provider-safety-net CI job, which runs build + test:providers-mocked + test:provider-structure + test:error-classifier-contract on every PR)": "",
|
|
182
182
|
"test:tool-routing": "pnpm exec tsx test/continuous-test-suite-tool-routing.ts",
|
|
183
183
|
"test:tool-routing-semantic": "pnpm exec tsx test/continuous-test-suite-tool-routing-semantic.ts",
|
|
184
|
-
"test:unit": "pnpm run test:bugfixes && pnpm run test:mcp:infra && pnpm run test:mcp:spans && pnpm run test:tool-routing && pnpm run test:tool-routing-cli && pnpm run test:tool-dedup && pnpm run test:model-pool && pnpm run test:classifier-router && pnpm run test:tool-routing-semantic && pnpm run test:mcp-result-cache && pnpm run test:mcp-direct-name-repair && pnpm run test:model-not-found-retryable && pnpm run test:archive:security && pnpm run test:office:security && pnpm run test:vector-chroma && pnpm run test:vector-pgvector && pnpm run test:vector-pinecone && pnpm run test:provider-wiring && pnpm run test:docs-mcp",
|
|
184
|
+
"test:unit": "pnpm run test:bugfixes && pnpm run test:mcp:infra && pnpm run test:mcp:spans && pnpm run test:tool-routing && pnpm run test:tool-routing-cli && pnpm run test:tool-dedup && pnpm run test:model-pool && pnpm run test:classifier-router && pnpm run test:tool-routing-semantic && pnpm run test:mcp-result-cache && pnpm run test:mcp-direct-name-repair && pnpm run test:mcp-breaker-resolved-errors && pnpm run test:model-not-found-retryable && pnpm run test:archive:security && pnpm run test:office:security && pnpm run test:vector-chroma && pnpm run test:vector-pgvector && pnpm run test:vector-pinecone && pnpm run test:provider-wiring && pnpm run test:docs-mcp",
|
|
185
185
|
"// CI tier — live providers, runs only when API keys are present (test:credentials and test:dynamic make real provider calls when keys are set, so they live here, not in test:unit; test:matrix, a different suite covering the full provider capability matrix, runs nightly via .github/workflows/live-matrix.yml — test:providers itself is still only wired into test:live, not any GitHub Actions workflow)": "",
|
|
186
186
|
"test:live": "pnpm run test:providers && pnpm run test:mcp:http && pnpm run test:mcp:sdk && pnpm run test:mcp:cli && pnpm run test:observability && pnpm run test:context && pnpm run test:memory && pnpm run test:tool-reliability && pnpm run test:evaluation && pnpm run test:autoresearch && pnpm run test:credentials && pnpm run test:dynamic",
|
|
187
187
|
"// CI tier — product output (image/video/TTS/PPT) — costs $$ per run (not wired into any GitHub Actions workflow as of this comment; run manually or add to live-matrix.yml if nightly coverage is needed)": "",
|
|
@@ -243,6 +243,7 @@
|
|
|
243
243
|
"test:multimodal": "pnpm run test:file-formats && pnpm run test:multimodal:sdk",
|
|
244
244
|
"test:multimodal:sdk": "pnpm exec tsx test/continuous-test-suite-multimodal-sdk.ts",
|
|
245
245
|
"test:mcp-result-cache": "pnpm exec tsx test/continuous-test-suite-mcp-result-cache.ts",
|
|
246
|
+
"test:mcp-breaker-resolved-errors": "pnpm exec tsx test/continuous-test-suite-mcp-breaker-resolved-errors.ts",
|
|
246
247
|
"test:archive:security": "pnpm exec tsx test/continuous-test-suite-archive-security.ts",
|
|
247
248
|
"test:office:security": "pnpm exec tsx test/continuous-test-suite-office-security.ts",
|
|
248
249
|
"test:vision-image-format": "pnpm exec tsx test/continuous-test-suite-vision-image-format.ts",
|