@juspay/neurolink 12.12.1 → 12.12.3

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.
@@ -118,6 +118,10 @@ export const textGenerationOptionsSchema = {
118
118
  type: "boolean",
119
119
  description: "Disable tool result caching for this request (overrides global mcp.cache.enabled).",
120
120
  },
121
+ disableInternalFallback: {
122
+ type: "boolean",
123
+ description: "Own fallback order yourself: skip NeuroLink's provider-priority walk and the catalog model fallback, so an invalid model or unavailable provider surfaces as its own error.",
124
+ },
121
125
  disableToolCallRepair: {
122
126
  type: "boolean",
123
127
  description: "Disable the schema-driven tool call repair mechanism (near-miss tool names, mis-typed arguments). Repair is enabled by default.",
@@ -1299,11 +1299,10 @@ export class BaseProvider {
1299
1299
  const otelSpanState = { ended: false };
1300
1300
  return await context.with(activeCtx, async () => this.runGenerateInActiveContext(options, startTime, otelSpan, otelSpanState));
1301
1301
  };
1302
- // TextGenerationOptions does not declare the flag (it lives on
1303
- // StreamOptions), but callers that own fallback order pass it on both
1304
- // paths, so honour it here as well.
1305
- const callerOwnsFallback = "disableInternalFallback" in options &&
1306
- options.disableInternalFallback === true;
1302
+ // Callers that own fallback order (providerFallback / modelChain callers,
1303
+ // or a router that retries on its own) pass the flag on both paths;
1304
+ // TextGenerationOptions declares it, so a plain read is enough here.
1305
+ const callerOwnsFallback = options.disableInternalFallback === true;
1307
1306
  return await this.runGenerateWithModelFallback(attempt, callerOwnsFallback);
1308
1307
  }
1309
1308
  /**
package/dist/neurolink.js CHANGED
@@ -4121,6 +4121,11 @@ Current user's request: ${currentInput}`;
4121
4121
  disableTools: options.disableTools,
4122
4122
  toolFilter: options.toolFilter,
4123
4123
  excludeTools: options.excludeTools,
4124
+ // This explicit field list is the only road into the provider, so a
4125
+ // flag left out here never reaches BaseProvider — which is exactly how
4126
+ // disableInternalFallback was dropped on generate() while stream()
4127
+ // (which spreads its options) honoured it.
4128
+ disableInternalFallback: options.disableInternalFallback,
4124
4129
  maxSteps: options.maxSteps,
4125
4130
  toolChoice: options.toolChoice,
4126
4131
  prepareStep: options.prepareStep,
@@ -5930,11 +5935,23 @@ Current user's request: ${currentInput}`;
5930
5935
  : requestedProvider
5931
5936
  ? [requestedProvider]
5932
5937
  : providerPriority;
5938
+ // The caller owns fallback order (a providerFallback / modelChain caller,
5939
+ // or a router that retries on its own): bound the walk to its first
5940
+ // candidate so an unavailable provider surfaces as its own error instead
5941
+ // of a silent switch. An explicit provider is already a one-element
5942
+ // list; this only changes the "auto" and orchestrated-preference walks.
5943
+ const providersToTry = options.disableInternalFallback === true
5944
+ ? tryProviders.slice(0, 1)
5945
+ : tryProviders;
5946
+ // Caller-owned fallback never enters tryProviders: providerFallback and
5947
+ // modelChain are walked by runWithFallbackOrchestration around the public
5948
+ // generate() call, and a configured ModelPool is consumed by the block
5949
+ // above. Slicing here therefore never clips a caller's own list.
5933
5950
  logger.debug(`[${functionTag}] Starting direct generation`, {
5934
5951
  requestedProvider: requestedProvider || "auto",
5935
5952
  preferredOrchestrated: preferredOrchestrated || "none",
5936
- tryProviders,
5937
- allowFallback: !requestedProvider || !!preferredOrchestrated,
5953
+ tryProviders: providersToTry,
5954
+ allowFallback: providersToTry.length > 1,
5938
5955
  });
5939
5956
  // ─── ModelPool path ──────────────────────────────────────────────────────
5940
5957
  // When a ModelPool is configured, source the candidate sequence from the
@@ -6080,7 +6097,7 @@ Current user's request: ${currentInput}`;
6080
6097
  // ─── End ModelPool path ───────────────────────────────────────────────────
6081
6098
  let lastError = null;
6082
6099
  // Try each provider in order
6083
- for (const providerName of tryProviders) {
6100
+ for (const providerName of providersToTry) {
6084
6101
  if (options.abortSignal?.aborted) {
6085
6102
  throw new DOMException("The operation was aborted", "AbortError");
6086
6103
  }
@@ -6437,7 +6454,7 @@ Current user's request: ${currentInput}`;
6437
6454
  // All providers failed
6438
6455
  const responseTime = Date.now() - startTime;
6439
6456
  logger.error(`[${functionTag}] All providers failed`, {
6440
- triedProviders: tryProviders,
6457
+ triedProviders: providersToTry,
6441
6458
  lastError: lastError?.message,
6442
6459
  responseTime,
6443
6460
  });
@@ -55,7 +55,7 @@ declare function resetEpochToMs(resetEpoch: number | undefined, now: number): nu
55
55
  * burst / acceleration limit) is transient: honor retry-after as a floor,
56
56
  * allow a couple of jittered same-account retries, then a short cooldown.
57
57
  */
58
- declare function planCooldownFor429(quota: AccountQuota | null, retryAfterMs: number, now: number, unifiedStatus?: string | undefined, policy?: ProxyOveragePolicy): AccountCooldownPlan;
58
+ declare function planCooldownFor429(quota: AccountQuota | null, retryAfterMs: number, now: number, unifiedStatus?: string | undefined, policy?: ProxyOveragePolicy, requestedModel?: string): AccountCooldownPlan;
59
59
  /**
60
60
  * Reconcile quota-backed cooldowns against a fresh provider observation. A
61
61
  * rejected window parks the account until its reset; an allowed observation for
@@ -553,6 +553,45 @@ function clampCooldownUntil(untilMs, now, reason) {
553
553
  const ceiling = Math.min(MAX_COOLDOWN_MS, (reason && MAX_COOLDOWN_MS_BY_REASON[reason]) ?? MAX_COOLDOWN_MS);
554
554
  return Math.min(Math.max(untilMs, now + MIN_COOLDOWN_MS), now + ceiling);
555
555
  }
556
+ function isAllowedQuotaStatus(status) {
557
+ return status?.trim().toLowerCase() === "allowed";
558
+ }
559
+ function isScopedWindowExhausted(window, now) {
560
+ if (!window || resetEpochToMs(window.resetsAt, now) === undefined) {
561
+ return false;
562
+ }
563
+ return (window.status?.trim().toLowerCase() === "rejected" ||
564
+ (window.used ?? 0) >= 1);
565
+ }
566
+ /**
567
+ * Anthropic represents some model-specific limits through a rejected top-level
568
+ * unified status. The scope window is the authoritative discriminator: only
569
+ * treat that response as model-scoped when both account-wide windows remain
570
+ * explicitly allowed and the requested model's window is exhausted.
571
+ */
572
+ function getScopedOnlyExhaustion(quota, requestedModel, now, policy = overagePolicy) {
573
+ if (!quota ||
574
+ !requestedModel ||
575
+ !isAllowedQuotaStatus(quota.sessionStatus) ||
576
+ !isAllowedQuotaStatus(quota.weeklyStatus) ||
577
+ isOverageUsable(quota, policy)) {
578
+ return null;
579
+ }
580
+ const scopedWindow = matchScopedQuotaWindow(quota, requestedModel, now);
581
+ return isScopedWindowExhausted(scopedWindow, now) ? scopedWindow : null;
582
+ }
583
+ function hasScopedOnlyExhaustion(quota, now, policy = overagePolicy) {
584
+ if (!isAllowedQuotaStatus(quota.sessionStatus) ||
585
+ !isAllowedQuotaStatus(quota.weeklyStatus) ||
586
+ isOverageUsable(quota, policy)) {
587
+ return false;
588
+ }
589
+ return (quota.windows ?? []).some((window) => window.kind === "weekly_scoped" &&
590
+ typeof window.scopeModel === "string" &&
591
+ now - scopedWindowObservedAt(quota, window) <=
592
+ QUOTA_SNAPSHOT_FRESHNESS_MS &&
593
+ isScopedWindowExhausted(window, now));
594
+ }
556
595
  /**
557
596
  * Decide how to cool an account after a genuine (non-anti-abuse) 429.
558
597
  *
@@ -567,13 +606,14 @@ function clampCooldownUntil(untilMs, now, reason) {
567
606
  * burst / acceleration limit) is transient: honor retry-after as a floor,
568
607
  * allow a couple of jittered same-account retries, then a short cooldown.
569
608
  */
570
- function planCooldownFor429(quota, retryAfterMs, now, unifiedStatus = quota?.unifiedStatus, policy = overagePolicy) {
609
+ function planCooldownFor429(quota, retryAfterMs, now, unifiedStatus = quota?.unifiedStatus, policy = overagePolicy, requestedModel) {
571
610
  // Weekly exhaustion takes precedence — it's the longest, hardest ceiling.
572
611
  if (quota && quota.weeklyStatus === "rejected") {
573
612
  const reset = resetEpochToMs(quota.weeklyResetAt, now) ??
574
613
  (retryAfterMs > 0 ? now + retryAfterMs : now + DEFAULT_COOLING_PERIOD_MS);
575
614
  return {
576
615
  reason: "weekly",
616
+ scope: "account",
577
617
  coolingUntil: clampCooldownUntil(reset, now, "weekly"),
578
618
  rotateImmediately: true,
579
619
  };
@@ -584,10 +624,25 @@ function planCooldownFor429(quota, retryAfterMs, now, unifiedStatus = quota?.uni
584
624
  (retryAfterMs > 0 ? now + retryAfterMs : now + DEFAULT_COOLING_PERIOD_MS);
585
625
  return {
586
626
  reason: "session",
627
+ scope: "account",
587
628
  coolingUntil: clampCooldownUntil(reset, now, "session"),
588
629
  rotateImmediately: true,
589
630
  };
590
631
  }
632
+ const scopedOnlyExhaustion = getScopedOnlyExhaustion(quota, requestedModel, now, policy);
633
+ if (scopedOnlyExhaustion) {
634
+ const reset = resetEpochToMs(scopedOnlyExhaustion.resetsAt, now) ??
635
+ (retryAfterMs > 0 ? now + retryAfterMs : now + DEFAULT_HARD_COOLDOWN_MS);
636
+ return {
637
+ // Keep the provider's top-level classification for logs, but do not
638
+ // persist it as an account cooldown. The scoped quota window gates only
639
+ // this model on subsequent requests.
640
+ reason: "unified",
641
+ scope: "model",
642
+ coolingUntil: reset,
643
+ rotateImmediately: true,
644
+ };
645
+ }
591
646
  // Anthropic may reject the authoritative top-level unified limit while both
592
647
  // 5h and 7d sub-window statuses still say "allowed". Treating this as a
593
648
  // transient burst retries a known-exhausted account and delays failover.
@@ -595,6 +650,7 @@ function planCooldownFor429(quota, retryAfterMs, now, unifiedStatus = quota?.uni
595
650
  const reset = retryAfterMs > 0 ? now + retryAfterMs : now + DEFAULT_HARD_COOLDOWN_MS;
596
651
  return {
597
652
  reason: "unified",
653
+ scope: "account",
598
654
  coolingUntil: clampCooldownUntil(reset, now, "unified"),
599
655
  rotateImmediately: true,
600
656
  };
@@ -605,6 +661,7 @@ function planCooldownFor429(quota, retryAfterMs, now, unifiedStatus = quota?.uni
605
661
  const base = retryAfterMs > 0 ? retryAfterMs : DEFAULT_COOLING_PERIOD_MS;
606
662
  return {
607
663
  reason: "transient",
664
+ scope: "account",
608
665
  coolingUntil: now +
609
666
  Math.max(MIN_COOLDOWN_MS, Math.min(base, TRANSIENT_MAX_COOLDOWN_MS)),
610
667
  rotateImmediately: false,
@@ -622,6 +679,7 @@ function minutesUntil(untilMs, now) {
622
679
  */
623
680
  function reconcileCooldownFromQuota(state, quota, now, policy = overagePolicy) {
624
681
  const overageAvailable = isOverageUsable(quota, policy);
682
+ const scopedOnlyExhaustion = hasScopedOnlyExhaustion(quota, now, policy);
625
683
  let until;
626
684
  let reason;
627
685
  if (quota.weeklyStatus === "rejected") {
@@ -646,7 +704,8 @@ function reconcileCooldownFromQuota(state, quota, now, policy = overagePolicy) {
646
704
  }
647
705
  else if (until === undefined &&
648
706
  quota.unifiedStatus === "rejected" &&
649
- !overageAvailable) {
707
+ !overageAvailable &&
708
+ !scopedOnlyExhaustion) {
650
709
  until = now + DEFAULT_HARD_COOLDOWN_MS;
651
710
  reason = "unified";
652
711
  }
@@ -656,7 +715,7 @@ function reconcileCooldownFromQuota(state, quota, now, policy = overagePolicy) {
656
715
  (state.coolingReason === "session" &&
657
716
  quota.sessionStatus === "allowed") ||
658
717
  (state.coolingReason === "unified" &&
659
- quota.unifiedStatus === "allowed"));
718
+ (quota.unifiedStatus === "allowed" || scopedOnlyExhaustion)));
660
719
  if (recoveredQuotaCooldown && state.coolingUntil) {
661
720
  const previousCoolingUntil = state.coolingUntil;
662
721
  const previousCoolingReason = state.coolingReason;
@@ -4348,13 +4407,14 @@ async function handleAnthropicStreamingSuccessResponse(args) {
4348
4407
  const quota = parseQuotaHeaders(responseHeaders, { model: body.model });
4349
4408
  const now = Date.now();
4350
4409
  if (isRateLimit) {
4351
- const cooldownPlan = planCooldownFor429(quota, parseRetryAfterMs(responseHeaders["retry-after"] ?? null), now, getUnifiedRateLimitStatus(responseHeaders));
4410
+ const cooldownPlan = planCooldownFor429(quota, parseRetryAfterMs(responseHeaders["retry-after"] ?? null), now, getUnifiedRateLimitStatus(responseHeaders), overagePolicy, typeof body.model === "string" ? body.model : undefined);
4352
4411
  accountState.quota = quota
4353
4412
  ? mergeQuotaSnapshot(accountState.quota, quota)
4354
4413
  : accountState.quota;
4355
4414
  const rateLimitKind = cooldownPlan.reason === "transient" ? "transient" : "quota";
4356
- if (!accountState.coolingUntil ||
4357
- cooldownPlan.coolingUntil > accountState.coolingUntil) {
4415
+ if (cooldownPlan.scope === "account" &&
4416
+ (!accountState.coolingUntil ||
4417
+ cooldownPlan.coolingUntil > accountState.coolingUntil)) {
4358
4418
  accountState.coolingUntil = cooldownPlan.coolingUntil;
4359
4419
  accountState.coolingReason = cooldownPlan.reason;
4360
4420
  await saveAccountCooldown(account.key, cooldownPlan.coolingUntil, cooldownPlan.reason).catch(() => {
@@ -5132,7 +5192,7 @@ async function handleAnthropicAuthRetry(args) {
5132
5192
  if (retryQuota429) {
5133
5193
  accountState.quota = mergeQuotaSnapshot(accountState.quota, retryQuota429);
5134
5194
  }
5135
- const retryPlan = planCooldownFor429(retryQuota429, parseRetryAfterMs(retryRespHeaders["retry-after"] ?? null), nowRetry, getUnifiedRateLimitStatus(retryRespHeaders));
5195
+ const retryPlan = planCooldownFor429(retryQuota429, parseRetryAfterMs(retryRespHeaders["retry-after"] ?? null), nowRetry, getUnifiedRateLimitStatus(retryRespHeaders), overagePolicy, typeof body.model === "string" ? body.model : undefined);
5136
5196
  const rateLimitKind = retryPlan.reason === "transient" ? "transient" : "quota";
5137
5197
  recordAttemptError(account.label, account.type, retryStatus, rateLimitKind);
5138
5198
  retryLogAttempt(429, "rate_limit_error", retryBody, {
@@ -5140,8 +5200,9 @@ async function handleAnthropicAuthRetry(args) {
5140
5200
  rateLimitKind,
5141
5201
  cooldownReason: retryPlan.reason,
5142
5202
  });
5143
- if (!accountState.coolingUntil ||
5144
- retryPlan.coolingUntil > accountState.coolingUntil) {
5203
+ if (retryPlan.scope === "account" &&
5204
+ (!accountState.coolingUntil ||
5205
+ retryPlan.coolingUntil > accountState.coolingUntil)) {
5145
5206
  accountState.coolingUntil = retryPlan.coolingUntil;
5146
5207
  accountState.coolingReason = retryPlan.reason;
5147
5208
  }
@@ -5150,9 +5211,11 @@ async function handleAnthropicAuthRetry(args) {
5150
5211
  // Non-fatal: routing already has the in-memory snapshot.
5151
5212
  });
5152
5213
  }
5153
- await saveAccountCooldown(account.key, accountState.coolingUntil ?? retryPlan.coolingUntil, accountState.coolingReason ?? retryPlan.reason).catch(() => {
5154
- // Non-fatal: routing already has the in-memory cooldown.
5155
- });
5214
+ if (retryPlan.scope === "account") {
5215
+ await saveAccountCooldown(account.key, accountState.coolingUntil ?? retryPlan.coolingUntil, accountState.coolingReason ?? retryPlan.reason).catch(() => {
5216
+ // Non-fatal: routing already has the in-memory cooldown.
5217
+ });
5218
+ }
5156
5219
  advancePrimaryIfCurrent(account.key, enabledAccounts.length, orderedAccounts[0]?.key);
5157
5220
  break;
5158
5221
  }
@@ -6149,10 +6212,10 @@ async function fetchAnthropicAccountResponse(args) {
6149
6212
  const now = Date.now();
6150
6213
  const quota = parseQuotaHeaders(errRespHeaders, { model: requestedModel });
6151
6214
  const unifiedStatus = getUnifiedRateLimitStatus(errRespHeaders);
6152
- const cooldownPlan = planCooldownFor429(quota, retryAfterMs, now, unifiedStatus);
6215
+ const cooldownPlan = planCooldownFor429(quota, retryAfterMs, now, unifiedStatus, overagePolicy, requestedModel);
6153
6216
  const rateLimitKind = cooldownPlan.reason === "transient" ? "transient" : "quota";
6154
6217
  recordAttemptError(account.label, account.type, 429, rateLimitKind);
6155
- logger.always(`[proxy] ← 429 account=${account.label} reason=${cooldownPlan.reason} ` +
6218
+ logger.always(`[proxy] ← 429 account=${account.label} reason=${cooldownPlan.reason} scope=${cooldownPlan.scope} ` +
6156
6219
  `retry-after=${retryAfterMs}ms 5h-status=${errRespHeaders["anthropic-ratelimit-unified-5h-status"] ?? "unknown"} ` +
6157
6220
  `7d-status=${errRespHeaders["anthropic-ratelimit-unified-7d-status"] ?? "unknown"} ` +
6158
6221
  `unified-status=${unifiedStatus ?? "unknown"} ` +
@@ -6507,14 +6570,15 @@ async function handleAnthropicRoutedClaudeRequest(args) {
6507
6570
  // Publish the cooldown before retrying so requests arriving behind
6508
6571
  // this one skip the throttled account instead of joining the burst.
6509
6572
  let cooldownExtended = false;
6510
- if (!accountState.coolingUntil ||
6511
- plan.coolingUntil > accountState.coolingUntil) {
6573
+ if (plan.scope === "account" &&
6574
+ (!accountState.coolingUntil ||
6575
+ plan.coolingUntil > accountState.coolingUntil)) {
6512
6576
  accountState.coolingUntil = plan.coolingUntil;
6513
6577
  accountState.coolingReason = plan.reason;
6514
6578
  cooldownExtended = true;
6515
6579
  }
6516
- if (cooldownExtended) {
6517
- await saveAccountCooldown(account.key, accountState.coolingUntil, accountState.coolingReason ?? plan.reason).catch(() => {
6580
+ if (cooldownExtended && plan.scope === "account") {
6581
+ await saveAccountCooldown(account.key, accountState.coolingUntil ?? plan.coolingUntil, accountState.coolingReason ?? plan.reason).catch(() => {
6518
6582
  // Non-fatal: routing already has the in-memory cooldown.
6519
6583
  });
6520
6584
  }
@@ -459,6 +459,19 @@ export type GenerateOptions = {
459
459
  skipToolPromptInjection?: boolean;
460
460
  /** Disable tool result caching for this request (overrides global mcp.cache.enabled) */
461
461
  disableToolCache?: boolean;
462
+ /**
463
+ * Disable NeuroLink's internal fallback for this request: the static
464
+ * provider-priority walk that runs when no provider was requested, and the
465
+ * catalog model-fallback walk a provider performs when its model is
466
+ * rejected as invalid. Callers that own fallback order (a caller-supplied
467
+ * `providerFallback` / `modelChain`, or a router that retries on its own,
468
+ * as the Claude proxy does for its streams) set this so an invalid model
469
+ * or an unavailable provider surfaces as exactly that.
470
+ * A configured `ModelPool`, `providerFallback` and `modelChain` are the
471
+ * caller's own fallback and are unaffected. Mirrors the same flag on
472
+ * `StreamOptions`.
473
+ */
474
+ disableInternalFallback?: boolean;
462
475
  /** Maximum number of tool execution steps (default: 200) */
463
476
  maxSteps?: number;
464
477
  /**
@@ -1204,6 +1217,14 @@ export type TextGenerationOptions = {
1204
1217
  excludeTools?: string[];
1205
1218
  /** Disable tool result caching for this request (overrides global mcp.cache.enabled) */
1206
1219
  disableToolCache?: boolean;
1220
+ /**
1221
+ * Caller owns fallback order. Read in two places: `directProviderGeneration`
1222
+ * bounds its static provider-priority walk to one candidate, and
1223
+ * `BaseProvider.generate()` skips the catalog model-fallback walk so an
1224
+ * invalid-model error surfaces as itself. Mapped from
1225
+ * `GenerateOptions.disableInternalFallback`.
1226
+ */
1227
+ disableInternalFallback?: boolean;
1207
1228
  /**
1208
1229
  * Tool choice configuration for the generation.
1209
1230
  * Controls whether and which tools the model must call.
@@ -856,7 +856,13 @@ export type PreparedAnthropicAccountAttempt = {
856
856
  export type RateLimitCoolingReason = Exclude<AccountCoolingReason, "auth">;
857
857
  export type AccountCooldownPlan = {
858
858
  reason: RateLimitCoolingReason;
859
- /** Epoch-ms until which the account should not be used. */
859
+ /**
860
+ * Whether this limit applies to every request on the account or only to the
861
+ * requested model. Model scope must never be persisted as an account
862
+ * cooldown; the quota window itself remains the routing evidence.
863
+ */
864
+ scope: "account" | "model";
865
+ /** Epoch-ms until which the limiting window is expected to recover. */
860
866
  coolingUntil: number;
861
867
  /** When true (unified/5h/7d rejected), rotate immediately — retrying the
862
868
  * same account is futile until its window resets. When false (transient
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.12.1",
3
+ "version": "12.12.3",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
6
6
  "author": {