@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.
@@ -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
- mcpLogger.debug(`[ExternalServerManager] Tool executed successfully: ${toolName} on ${serverId}`, {
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, true);
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
- execute<T>(operation: () => Promise<T>): Promise<T>;
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
- // Emit failure event
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
- const result = await circuitBreaker.execute(async () => {
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
- this.updateToolStats(toolKey, true, duration);
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 cooldownPlan = planCooldownFor429(quota, parseRetryAfterMs(responseHeaders["retry-after"] ?? null), now, getUnifiedRateLimitStatus(responseHeaders), overagePolicy, typeof body.model === "string" ? body.model : undefined);
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 sharedRetrySlot = fetchResult.retrySameAccount
7375
- ? claimTransientRateLimitRetry(account.key, plan.coolingUntil)
7423
+ const retry = fetchResult.retrySameAccount
7424
+ ? claimTransientRateLimitRetryDelay(account.key, plan.coolingUntil, fetchResult.retryAfterMs, rateLimitSameAccountRetries)
7376
7425
  : undefined;
7377
- if (fetchResult.retrySameAccount &&
7378
- fetchResult.retryAfterMs !== undefined &&
7379
- rateLimitSameAccountRetries <
7380
- MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES &&
7381
- sharedRetrySlot !== undefined) {
7426
+ if (retry) {
7382
7427
  rateLimitSameAccountRetries += 1;
7383
- const base = Math.min(fetchResult.retryAfterMs || 1_000, MAX_RATE_LIMIT_RETRY_DELAY_MS);
7384
- // Stagger the two shared slots, then cap after jitter so the final
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 */
@@ -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 | 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 | 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\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 see what is currently parked, and\n to release one.","hierarchy":{"lvl0":"Features","lvl1":"Claude Proxy","lvl2":"Cooldown and backoff","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 | 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.0",
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",