@juspay/neurolink 10.8.2 → 10.8.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.
@@ -5,6 +5,7 @@ import { MiddlewareFactory } from "../middleware/factory.js";
5
5
  import { modelSupports } from "../models/modelRegistry.js";
6
6
  import { ATTR, tracers } from "../telemetry/index.js";
7
7
  import { isAbortError, NeuroLinkError } from "../utils/errorHandling.js";
8
+ import { duckTypedStatusCode, extractRetryAfterMsFromError, } from "../utils/providerRetry.js";
8
9
  import { hasLifecycleErrorFired, markLifecycleErrorFired, } from "../utils/lifecycleCallbacks.js";
9
10
  import { resolveLifecycleTimeoutMs } from "../utils/lifecycleTimeout.js";
10
11
  import { logger } from "../utils/logger.js";
@@ -1488,6 +1489,30 @@ export class BaseProvider {
1488
1489
  : new DOMException("The operation was aborted", "AbortError");
1489
1490
  }
1490
1491
  const formatted = this.formatProviderError(error);
1492
+ // Preserve transport retry metadata across formatting. Provider
1493
+ // formatters return fresh Error instances (RateLimitError, NetworkError,
1494
+ // …) that would otherwise destroy the classification upper layers need:
1495
+ // performMCPGenerationRetries' isRetryable/status checks and
1496
+ // providerRetry's Retry-After extraction. Copied generically so every
1497
+ // provider's 429/5xx keeps its status and server-requested delay.
1498
+ if (error && typeof error === "object" && formatted !== error) {
1499
+ const src = error;
1500
+ const dst = formatted;
1501
+ const statusCode = duckTypedStatusCode(error);
1502
+ if (statusCode !== undefined && dst.statusCode === undefined) {
1503
+ dst.statusCode = statusCode;
1504
+ }
1505
+ if (typeof src.isRetryable === "boolean" &&
1506
+ dst.isRetryable === undefined) {
1507
+ dst.isRetryable = src.isRetryable;
1508
+ }
1509
+ if (dst.retryAfterMs === undefined) {
1510
+ const retryAfterMs = extractRetryAfterMsFromError(error);
1511
+ if (retryAfterMs !== undefined) {
1512
+ dst.retryAfterMs = retryAfterMs;
1513
+ }
1514
+ }
1515
+ }
1491
1516
  // Preserve the lifecycle-fired mark across formatting:
1492
1517
  // fireLifecycleErrorCallback() marks the ORIGINAL error in the shared
1493
1518
  // WeakSet, but formatProviderError() typically returns a new Error
@@ -45,6 +45,30 @@ const IMAGE_LIMITS = {
45
45
  * through and let the underlying provider surface errors if needed.
46
46
  */
47
47
  const PROXY_PROVIDERS = new Set(["litellm", "openrouter"]);
48
+ /**
49
+ * Family-level vision rules, checked ONLY after a model id misses the exact
50
+ * VISION_CAPABILITIES allowlist for its provider. Kept as patterns (mirroring
51
+ * SAMPLING_PARAM_REJECTING_FAMILIES in modelRegistry.ts) because gateway ids
52
+ * carry arbitrary prefixes/suffixes (`vertex_ai/claude-sonnet-5@20260203`,
53
+ * `claude-opus-4-7-20260115`) that exact entries can't cover.
54
+ *
55
+ * Matches name-first Claude ids (claude-{opus,sonnet,haiku}-N…) with major
56
+ * version ≥ 4 — e.g. `claude-sonnet-5`, `claude-opus-5`, `claude-opus-4-7`,
57
+ * `claude-haiku-4-5` — all of which are vision-capable, plus the
58
+ * Fable/Mythos-class models (`claude-fable-5`, `claude-mythos-5`).
59
+ *
60
+ * Legacy number-first 3.x ids (`claude-3-5-haiku`, `claude-3-haiku`, …)
61
+ * deliberately do NOT match: the family word follows the version there, and
62
+ * claude-3-5-haiku — the last non-vision Claude — must stay rejected.
63
+ */
64
+ const CLAUDE_MODERN_VISION_FAMILIES = [
65
+ /claude-(?:opus|sonnet|haiku)-(?:[4-9]|\d{2,})/i,
66
+ /claude-(?:fable|mythos)-\d/i,
67
+ ];
68
+ const VISION_FAMILY_RULES = {
69
+ anthropic: CLAUDE_MODERN_VISION_FAMILIES,
70
+ vertex: CLAUDE_MODERN_VISION_FAMILIES,
71
+ };
48
72
  /**
49
73
  * Normalize provider name/alias to its canonical form for vision checks.
50
74
  */
@@ -597,6 +621,13 @@ export class ProviderImageAdapter {
597
621
  static supportsVision(provider, model) {
598
622
  try {
599
623
  const normalizedProvider = normalizeVisionProvider(provider);
624
+ // Anthropic behind a proxy (ANTHROPIC_BASE_URL set) routes to whatever
625
+ // the proxy exposes — capability gating is the upstream's job. Mirrors
626
+ // the validateModelAccess tier-check bypass in providers/anthropic.ts.
627
+ if (normalizedProvider === "anthropic" &&
628
+ process.env.ANTHROPIC_BASE_URL) {
629
+ return true;
630
+ }
600
631
  const supportedModels = VISION_CAPABILITIES[normalizedProvider];
601
632
  if (!supportedModels) {
602
633
  return false;
@@ -612,6 +643,10 @@ export class ProviderImageAdapter {
612
643
  return true; // Provider supports vision, but need to check specific model
613
644
  }
614
645
  const modelMatched = supportedModels.some((supportedModel) => model.toLowerCase().includes(supportedModel.toLowerCase()));
646
+ if (!modelMatched &&
647
+ VISION_FAMILY_RULES[normalizedProvider]?.some((rule) => rule.test(model))) {
648
+ return true;
649
+ }
615
650
  // Proxy providers route to arbitrary underlying models — pass through if
616
651
  // the model isn't in the known allowlist.
617
652
  if (!modelMatched && PROXY_PROVIDERS.has(normalizedProvider)) {
@@ -5,6 +5,7 @@ import { MiddlewareFactory } from "../middleware/factory.js";
5
5
  import { modelSupports } from "../models/modelRegistry.js";
6
6
  import { ATTR, tracers } from "../telemetry/index.js";
7
7
  import { isAbortError, NeuroLinkError } from "../utils/errorHandling.js";
8
+ import { duckTypedStatusCode, extractRetryAfterMsFromError, } from "../utils/providerRetry.js";
8
9
  import { hasLifecycleErrorFired, markLifecycleErrorFired, } from "../utils/lifecycleCallbacks.js";
9
10
  import { resolveLifecycleTimeoutMs } from "../utils/lifecycleTimeout.js";
10
11
  import { logger } from "../utils/logger.js";
@@ -1488,6 +1489,30 @@ export class BaseProvider {
1488
1489
  : new DOMException("The operation was aborted", "AbortError");
1489
1490
  }
1490
1491
  const formatted = this.formatProviderError(error);
1492
+ // Preserve transport retry metadata across formatting. Provider
1493
+ // formatters return fresh Error instances (RateLimitError, NetworkError,
1494
+ // …) that would otherwise destroy the classification upper layers need:
1495
+ // performMCPGenerationRetries' isRetryable/status checks and
1496
+ // providerRetry's Retry-After extraction. Copied generically so every
1497
+ // provider's 429/5xx keeps its status and server-requested delay.
1498
+ if (error && typeof error === "object" && formatted !== error) {
1499
+ const src = error;
1500
+ const dst = formatted;
1501
+ const statusCode = duckTypedStatusCode(error);
1502
+ if (statusCode !== undefined && dst.statusCode === undefined) {
1503
+ dst.statusCode = statusCode;
1504
+ }
1505
+ if (typeof src.isRetryable === "boolean" &&
1506
+ dst.isRetryable === undefined) {
1507
+ dst.isRetryable = src.isRetryable;
1508
+ }
1509
+ if (dst.retryAfterMs === undefined) {
1510
+ const retryAfterMs = extractRetryAfterMsFromError(error);
1511
+ if (retryAfterMs !== undefined) {
1512
+ dst.retryAfterMs = retryAfterMs;
1513
+ }
1514
+ }
1515
+ }
1491
1516
  // Preserve the lifecycle-fired mark across formatting:
1492
1517
  // fireLifecycleErrorCallback() marks the ORIGINAL error in the shared
1493
1518
  // WeakSet, but formatProviderError() typically returns a new Error
@@ -100,6 +100,7 @@ import { runWorkflow } from "./workflow/core/workflowRunner.js";
100
100
  import { ModelPool, classifyProviderError } from "./routing/index.js";
101
101
  import { ClassifierRouter } from "./routing/classifierRouter.js";
102
102
  import { looksLikeModelAccessDenied as sharedLooksLikeModelAccessDenied, isNonRetryableProviderError as sharedIsNonRetryableProviderError, } from "./utils/providerErrorClassification.js";
103
+ import { getErrorStatusCode } from "./utils/providerRetry.js";
103
104
  import { detectAndRedactPII } from "./utils/piiDetector.js";
104
105
  import { validateResponse } from "./utils/responseValidator.js";
105
106
  /**
@@ -3298,10 +3299,26 @@ Current user's request: ${currentInput}`;
3298
3299
  const effectiveCallback = perCallCallback ?? this.fallbackConfig.providerFallback;
3299
3300
  const effectiveChain = perCallChain ?? this.fallbackConfig.modelChain;
3300
3301
  // Explicit callback (per-call or instance providerFallback): the callback
3301
- // owns the decision for any error except client aborts — it can return
3302
- // null to bubble. modelChain-only keeps the narrow model-access-denied
3303
- // gate so chain walkers don't retry errors the chain can't fix.
3304
- const shouldOrchestrateFallback = (err) => effectiveCallback ? !isAbortError(err) : looksLikeModelAccessDenied(err);
3302
+ // owns the decision for any error except genuine caller cancels — it can
3303
+ // return null to bubble. modelChain-only keeps the narrow
3304
+ // model-access-denied gate so chain walkers don't retry errors the chain
3305
+ // can't fix.
3306
+ //
3307
+ // Error shape alone cannot identify a caller cancel: NeuroLink's own
3308
+ // watchdog/timeout controllers abort the same composed signal, and SDKs
3309
+ // normalize that into the identical AbortError shapes a user cancel
3310
+ // produces (e.g. Anthropic's APIUserAbortError). Ground truth is the
3311
+ // caller-supplied abortSignal itself — only when IT has fired is an
3312
+ // abort-shaped error a real cancel; otherwise the abort was internally
3313
+ // initiated (turn/stall watchdog, per-step timeout) and the callback
3314
+ // must still be consulted so provider hangs can fall back. Read live
3315
+ // (not captured) so the post-retry re-gate sees a cancel that arrives
3316
+ // mid-fallback; cloneOptionsForCallIsolation keeps abortSignal
3317
+ // by-reference, so the retried options observe the same signal.
3318
+ const callerAborted = () => callOpts.abortSignal?.aborted === true;
3319
+ const shouldOrchestrateFallback = (err) => effectiveCallback
3320
+ ? !(isAbortError(err) && callerAborted())
3321
+ : looksLikeModelAccessDenied(err);
3305
3322
  if (!shouldOrchestrateFallback(lastError)) {
3306
3323
  throw lastError;
3307
3324
  }
@@ -5235,7 +5252,12 @@ Current user's request: ${currentInput}`;
5235
5252
  error.isRetryable ===
5236
5253
  false) ||
5237
5254
  (error instanceof Error &&
5238
- error.statusCode === 400);
5255
+ error.statusCode === 400) ||
5256
+ // A 429 already went through the provider layer's bounded,
5257
+ // Retry-After-aware retry (withProviderRetry) — re-running the
5258
+ // whole MCP generation against a rate-limited key is redundant
5259
+ // and only delays fallback orchestration.
5260
+ getErrorStatusCode(error) === 429;
5239
5261
  if (isNonRetryable) {
5240
5262
  logger.debug(`[${functionTag}] Non-retryable error detected, skipping remaining retries`);
5241
5263
  break;
@@ -608,6 +608,12 @@ export class AnthropicProvider extends BaseProvider {
608
608
  // Note: No headers passed - fetch wrapper sets oauth-2025-04-20 beta header
609
609
  fetch: oauthFetch,
610
610
  timeout: ANTHROPIC_CLIENT_TIMEOUT_MS,
611
+ // The SDK's built-in retry honors Retry-After hints without any
612
+ // upper bound (a 429 with retry-after: 8549 sleeps 2.4h per retry,
613
+ // invisible to fallback orchestration). Retries are the
614
+ // orchestrator's job; transient network blips are still retried by
615
+ // the fetch wrapper.
616
+ maxRetries: 0,
611
617
  });
612
618
  logger.debug("[AnthropicProvider] Anthropic SDK client created with OAuth fetch wrapper");
613
619
  logger.debug("Anthropic Provider initialized with OAuth", {
@@ -654,6 +660,9 @@ export class AnthropicProvider extends BaseProvider {
654
660
  ...(normalizedBaseURL && { baseURL: normalizedBaseURL }),
655
661
  fetch: createProxyFetch(),
656
662
  timeout: ANTHROPIC_CLIENT_TIMEOUT_MS,
663
+ // See the OAuth-path client above: unbounded Retry-After sleeps in
664
+ // the SDK's retry loop must never stall fallback orchestration.
665
+ maxRetries: 0,
657
666
  });
658
667
  logger.debug("Anthropic Provider initialized with API key", {
659
668
  modelName: this.modelName,
@@ -532,6 +532,10 @@ const createVertexAnthropicSettings = async (region, timeoutMs) => {
532
532
  projectId: project,
533
533
  region: location,
534
534
  ...(timeoutMs !== undefined && { timeout: timeoutMs }),
535
+ // The SDK's built-in retry honors Retry-After hints without any upper
536
+ // bound (a 429 with retry-after: 8549 sleeps 2.4h per retry, invisible
537
+ // to fallback orchestration). Retries are the orchestrator's job.
538
+ maxRetries: 0,
535
539
  };
536
540
  };
537
541
  // Helper function to determine if a model is an Anthropic model
@@ -612,6 +612,15 @@ export const buildAPIError = async (url, body, res) => {
612
612
  `OpenAI-compatible request failed with status ${res.status}`;
613
613
  const err = new Error(msg);
614
614
  err.statusCode = res.status;
615
+ // Response headers carry rate-limit hints (Retry-After, X-RateLimit-*)
616
+ // that withProviderRetry needs to honor short waits and surface long ones.
617
+ // Allowlisted to just those — attaching every header would let unrelated
618
+ // (potentially sensitive) values like set-cookie or internal routing
619
+ // headers ride along when the error is logged.
620
+ err.responseHeaders = Object.fromEntries([...res.headers.entries()].filter(([key]) => {
621
+ const lower = key.toLowerCase();
622
+ return lower === "retry-after" || lower.startsWith("x-ratelimit-");
623
+ }));
615
624
  err.url = url;
616
625
  // Redacted summary only — never attach raw prompts, tool definitions, or
617
626
  // tool arguments to the thrown error. Anything serialized by upstream
@@ -29,12 +29,15 @@ export type NeuroLinkConfig = {
29
29
  /**
30
30
  * Curator P2-3: callback signature for centralized fallback policy. When an
31
31
  * explicit callback is configured (per-call or instance), it is invoked for
32
- * ANY error thrown by a generate/stream call except client aborts network
33
- * errors, 5xx, timeouts, auth failures included. The callback receives the
34
- * error unmodified so hosts can classify it themselves (status codes,
35
- * `isNonRetryableProviderError`, …). Return `{ provider, model }` (either /
36
- * both optional) to drive a retry; return `null` to bubble the original
37
- * error untouched.
32
+ * ANY error thrown by a generate/stream call except genuine caller cancels
33
+ * network errors, 5xx, timeouts, auth failures included. A caller cancel is
34
+ * identified by the caller-supplied `abortSignal` having fired, not by error
35
+ * shape: abort-shaped errors from NeuroLink's own turn/stall watchdogs and
36
+ * per-step timeouts DO invoke the callback, so provider hangs can fall back.
37
+ * The callback receives the error unmodified so hosts can classify it
38
+ * themselves (status codes, `isNonRetryableProviderError`, …). Return
39
+ * `{ provider, model }` (either / both optional) to drive a retry; return
40
+ * `null` to bubble the original error untouched.
38
41
  */
39
42
  export type ProviderFallbackCallback = (error: unknown) => Promise<{
40
43
  provider?: string;
@@ -70,11 +73,13 @@ export type NeurolinkConstructorConfig = {
70
73
  credentials?: NeurolinkCredentials;
71
74
  /**
72
75
  * Curator P2-3: callback invoked when a generate/stream call fails with
73
- * any error except a client abort (network errors, 5xx, timeouts, auth
74
- * failures, model-access-denied, …). Lets a host (e.g. Curator) centrally
75
- * drive fallback policy "provider A primary, provider B on failure".
76
- * The callback receives the original error unmodified and returns the
77
- * next `{ provider, model }` to try, or `null` to bubble the error.
76
+ * any error except a genuine caller cancel i.e. the caller-supplied
77
+ * `abortSignal` fired (network errors, 5xx, timeouts, auth failures,
78
+ * model-access-denied, and internal watchdog aborts all invoke it). Lets
79
+ * a host (e.g. Curator) centrally drive fallback policy "provider A
80
+ * primary, provider B on failure". The callback receives the original
81
+ * error unmodified and returns the next `{ provider, model }` to try, or
82
+ * `null` to bubble the error.
78
83
  */
79
84
  providerFallback?: ProviderFallbackCallback;
80
85
  /**
@@ -577,9 +577,11 @@ export type GenerateOptions = {
577
577
  /**
578
578
  * Curator P2-3: per-call fallback callback. Overrides any
579
579
  * instance-level `providerFallback` set on `new NeuroLink({...})`.
580
- * Invoked for any error except client aborts (network errors, 5xx,
581
- * timeouts, auth failures, model-access-denied, …); receives the error
582
- * unmodified. Return `{ provider, model }` to retry, `null` to bubble.
580
+ * Invoked for any error except a genuine caller cancel — i.e. this
581
+ * call's `abortSignal` fired (network errors, 5xx, timeouts, auth
582
+ * failures, model-access-denied, and internal watchdog aborts all invoke
583
+ * it); receives the error unmodified. Return `{ provider, model }` to
584
+ * retry, `null` to bubble.
583
585
  */
584
586
  providerFallback?: (error: unknown) => Promise<{
585
587
  provider?: string;
@@ -925,6 +925,8 @@ export type AnthropicVertexSettings = {
925
925
  region: string;
926
926
  /** SDK request timeout in milliseconds */
927
927
  timeout?: number;
928
+ /** SDK-internal retry budget (transport retries are the orchestrator's job) */
929
+ maxRetries?: number;
928
930
  };
929
931
  /**
930
932
  * OpenAI-compatible models endpoint response structure
@@ -487,11 +487,12 @@ export type StreamOptions = {
487
487
  /**
488
488
  * Curator P2-3: per-call fallback callback. Overrides any
489
489
  * instance-level `providerFallback` set on `new NeuroLink({...})`.
490
- * Invoked for any error thrown while establishing the stream, except
491
- * client aborts (network errors, 5xx, timeouts, auth failures,
492
- * model-access-denied, …); receives the error unmodified. There is no
493
- * mid-stream resume once chunks are flowing. Return `{ provider,
494
- * model }` to retry, `null` to bubble.
490
+ * Invoked for any error thrown while establishing the stream, except a
491
+ * genuine caller cancel i.e. this call's `abortSignal` fired (network
492
+ * errors, 5xx, timeouts, auth failures, model-access-denied, and
493
+ * internal watchdog aborts all invoke it); receives the error
494
+ * unmodified. There is no mid-stream resume once chunks are flowing.
495
+ * Return `{ provider, model }` to retry, `null` to bubble.
495
496
  */
496
497
  providerFallback?: (error: unknown) => Promise<{
497
498
  provider?: string;
@@ -20,8 +20,16 @@ export declare const MAX_PROVIDER_RETRIES = 2;
20
20
  export declare const BASE_RETRY_DELAY_MS = 1000;
21
21
  /** Minimum delay in ms when a retryable response provides no retry timing. */
22
22
  export declare const NO_HINT_FLOOR_MS = 10000;
23
- /** Maximum server-requested retry delay honored by provider retries. */
24
- export declare const MAX_RETRY_AFTER_MS = 120000;
23
+ /**
24
+ * Maximum server-requested retry delay honored by provider retries.
25
+ *
26
+ * A hint above this cap is not clamped-and-slept: the server declared an
27
+ * unavailability window we are not willing to wait out, so the error is
28
+ * surfaced immediately instead — fallback orchestration (providerFallback /
29
+ * modelChain) can route to another provider, and callers without fallback
30
+ * get a prompt rate-limit error rather than a silent multi-minute stall.
31
+ */
32
+ export declare const MAX_RETRY_AFTER_MS = 60000;
25
33
  /**
26
34
  * Check whether an error thrown by the AI SDK is retryable.
27
35
  *
@@ -29,6 +37,21 @@ export declare const MAX_RETRY_AFTER_MS = 120000;
29
37
  * uses a branded symbol marker, so `instanceof` doesn't work across package
30
38
  * boundaries). Falls back to duck-typing for non-APICallError cases.
31
39
  */
40
+ /**
41
+ * Duck-typed HTTP status extraction for errors of unknown provenance.
42
+ * NeuroLink's hand-rolled clients stamp `.statusCode`; official SDK errors
43
+ * (e.g. @anthropic-ai/sdk APIError) expose `.status`. Shared with
44
+ * baseProvider's handleProviderError metadata preservation.
45
+ */
46
+ export declare function duckTypedStatusCode(error: unknown): number | undefined;
47
+ /**
48
+ * Duck-typed Retry-After extraction for errors of unknown provenance.
49
+ * Reads a pre-parsed `.retryAfterMs` (stamped by metadata-preserving
50
+ * wrappers like handleProviderError), then `.headers` (Headers instance or
51
+ * record — e.g. @anthropic-ai/sdk APIError), then `.responseHeaders` (the
52
+ * hand-rolled OpenAI-compatible client's record). Shared with baseProvider.
53
+ */
54
+ export declare function extractRetryAfterMsFromError(error: unknown): number | undefined;
32
55
  export declare function isRetryableProviderError(error: unknown): boolean;
33
56
  /**
34
57
  * Extract the HTTP status code from an AI SDK error, if available.
@@ -24,8 +24,16 @@ export const MAX_PROVIDER_RETRIES = 2;
24
24
  export const BASE_RETRY_DELAY_MS = 1000;
25
25
  /** Minimum delay in ms when a retryable response provides no retry timing. */
26
26
  export const NO_HINT_FLOOR_MS = 10_000;
27
- /** Maximum server-requested retry delay honored by provider retries. */
28
- export const MAX_RETRY_AFTER_MS = 120_000;
27
+ /**
28
+ * Maximum server-requested retry delay honored by provider retries.
29
+ *
30
+ * A hint above this cap is not clamped-and-slept: the server declared an
31
+ * unavailability window we are not willing to wait out, so the error is
32
+ * surfaced immediately instead — fallback orchestration (providerFallback /
33
+ * modelChain) can route to another provider, and callers without fallback
34
+ * get a prompt rate-limit error rather than a silent multi-minute stall.
35
+ */
36
+ export const MAX_RETRY_AFTER_MS = 60_000;
29
37
  const sleepWithTimeout = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs));
30
38
  /**
31
39
  * Check whether an error thrown by the AI SDK is retryable.
@@ -34,6 +42,54 @@ const sleepWithTimeout = (delayMs) => new Promise((resolve) => setTimeout(resolv
34
42
  * uses a branded symbol marker, so `instanceof` doesn't work across package
35
43
  * boundaries). Falls back to duck-typing for non-APICallError cases.
36
44
  */
45
+ /**
46
+ * Duck-typed HTTP status extraction for errors of unknown provenance.
47
+ * NeuroLink's hand-rolled clients stamp `.statusCode`; official SDK errors
48
+ * (e.g. @anthropic-ai/sdk APIError) expose `.status`. Shared with
49
+ * baseProvider's handleProviderError metadata preservation.
50
+ */
51
+ export function duckTypedStatusCode(error) {
52
+ if (!error || typeof error !== "object") {
53
+ return undefined;
54
+ }
55
+ const err = error;
56
+ if (typeof err.statusCode === "number") {
57
+ return err.statusCode;
58
+ }
59
+ if (typeof err.status === "number") {
60
+ return err.status;
61
+ }
62
+ return undefined;
63
+ }
64
+ /**
65
+ * Duck-typed Retry-After extraction for errors of unknown provenance.
66
+ * Reads a pre-parsed `.retryAfterMs` (stamped by metadata-preserving
67
+ * wrappers like handleProviderError), then `.headers` (Headers instance or
68
+ * record — e.g. @anthropic-ai/sdk APIError), then `.responseHeaders` (the
69
+ * hand-rolled OpenAI-compatible client's record). Shared with baseProvider.
70
+ */
71
+ export function extractRetryAfterMsFromError(error) {
72
+ if (!error || typeof error !== "object") {
73
+ return undefined;
74
+ }
75
+ const err = error;
76
+ // Number.isFinite rejects NaN/Infinity: typeof NaN === "number", and a NaN
77
+ // hint would survive Math.max() into sleep(NaN) — an immediate retry
78
+ // instead of the no-hint floor.
79
+ if (typeof err.retryAfterMs === "number" &&
80
+ Number.isFinite(err.retryAfterMs)) {
81
+ return err.retryAfterMs;
82
+ }
83
+ for (const candidate of [err.headers, err.responseHeaders]) {
84
+ if (candidate && typeof candidate === "object") {
85
+ const parsed = parseRetryAfterMs(candidate);
86
+ if (parsed !== undefined) {
87
+ return parsed;
88
+ }
89
+ }
90
+ }
91
+ return undefined;
92
+ }
37
93
  export function isRetryableProviderError(error) {
38
94
  // Preferred path: use the AI SDK's own branded type check + isRetryable flag
39
95
  if (APICallError.isInstance(error)) {
@@ -43,8 +99,8 @@ export function isRetryableProviderError(error) {
43
99
  return error.retriable;
44
100
  }
45
101
  // Fallback: duck-type for status codes on errors that aren't APICallError
46
- if (error && typeof error === "object" && "statusCode" in error) {
47
- const statusCode = error.statusCode;
102
+ const statusCode = duckTypedStatusCode(error);
103
+ if (statusCode !== undefined) {
48
104
  return statusCode === 429 || statusCode >= 500;
49
105
  }
50
106
  return false;
@@ -56,10 +112,7 @@ export function getErrorStatusCode(error) {
56
112
  if (APICallError.isInstance(error)) {
57
113
  return error.statusCode;
58
114
  }
59
- if (error && typeof error === "object" && "statusCode" in error) {
60
- return error.statusCode;
61
- }
62
- return undefined;
115
+ return duckTypedStatusCode(error);
63
116
  }
64
117
  function getRetryAfterMs(error) {
65
118
  if (APICallError.isInstance(error) && error.responseHeaders) {
@@ -68,10 +121,12 @@ function getRetryAfterMs(error) {
68
121
  return parsedDelay;
69
122
  }
70
123
  }
71
- if (error instanceof NeuroLinkError && error.retryAfterMs !== undefined) {
124
+ if (error instanceof NeuroLinkError &&
125
+ error.retryAfterMs !== undefined &&
126
+ Number.isFinite(error.retryAfterMs)) {
72
127
  return error.retryAfterMs;
73
128
  }
74
- return undefined;
129
+ return extractRetryAfterMsFromError(error);
75
130
  }
76
131
  /**
77
132
  * Execute a provider call with instrumented retry logic.
@@ -111,10 +166,29 @@ export async function withProviderRetry(operation, span, label, sleep = sleepWit
111
166
  throw error;
112
167
  }
113
168
  const retryAfterMs = getRetryAfterMs(error);
114
- const boundedRetryAfterMs = retryAfterMs === undefined
115
- ? undefined
116
- : Math.min(MAX_RETRY_AFTER_MS, Math.max(0, retryAfterMs));
117
- const delay = boundedRetryAfterMs ??
169
+ if (retryAfterMs !== undefined && retryAfterMs > MAX_RETRY_AFTER_MS) {
170
+ span?.setAttribute("gen_ai.provider.total_attempts", attempt + 1);
171
+ span?.addEvent("gen_ai.provider.retry_suppressed", {
172
+ "retry.after_ms": retryAfterMs,
173
+ ...(statusCode !== undefined && { "retry.status_code": statusCode }),
174
+ });
175
+ // Best-effort stamp so upper retry layers (the MCP generation loop's
176
+ // isRetryable check) also surface instead of re-running a
177
+ // rate-limited provider.
178
+ try {
179
+ Object.assign(error, {
180
+ isRetryable: false,
181
+ retryAfterMs,
182
+ });
183
+ }
184
+ catch {
185
+ /* frozen error — the throw below still surfaces it */
186
+ }
187
+ logger.warn(`[providerRetry] ${label} not retrying — server requested a ` +
188
+ `${Math.round(retryAfterMs / 1000)}s wait (cap ${MAX_RETRY_AFTER_MS / 1000}s); surfacing for fallback`, { attempt: attempt + 1, retryAfterMs, statusCode });
189
+ throw error;
190
+ }
191
+ const delay = (retryAfterMs === undefined ? undefined : Math.max(0, retryAfterMs)) ??
118
192
  Math.max(BASE_RETRY_DELAY_MS * Math.pow(2, attempt), NO_HINT_FLOOR_MS);
119
193
  // Record retry event on the OTel span
120
194
  span?.addEvent("gen_ai.provider.retry", {
package/dist/neurolink.js CHANGED
@@ -100,6 +100,7 @@ import { runWorkflow } from "./workflow/core/workflowRunner.js";
100
100
  import { ModelPool, classifyProviderError } from "./routing/index.js";
101
101
  import { ClassifierRouter } from "./routing/classifierRouter.js";
102
102
  import { looksLikeModelAccessDenied as sharedLooksLikeModelAccessDenied, isNonRetryableProviderError as sharedIsNonRetryableProviderError, } from "./utils/providerErrorClassification.js";
103
+ import { getErrorStatusCode } from "./utils/providerRetry.js";
103
104
  import { detectAndRedactPII } from "./utils/piiDetector.js";
104
105
  import { validateResponse } from "./utils/responseValidator.js";
105
106
  /**
@@ -3298,10 +3299,26 @@ Current user's request: ${currentInput}`;
3298
3299
  const effectiveCallback = perCallCallback ?? this.fallbackConfig.providerFallback;
3299
3300
  const effectiveChain = perCallChain ?? this.fallbackConfig.modelChain;
3300
3301
  // Explicit callback (per-call or instance providerFallback): the callback
3301
- // owns the decision for any error except client aborts — it can return
3302
- // null to bubble. modelChain-only keeps the narrow model-access-denied
3303
- // gate so chain walkers don't retry errors the chain can't fix.
3304
- const shouldOrchestrateFallback = (err) => effectiveCallback ? !isAbortError(err) : looksLikeModelAccessDenied(err);
3302
+ // owns the decision for any error except genuine caller cancels — it can
3303
+ // return null to bubble. modelChain-only keeps the narrow
3304
+ // model-access-denied gate so chain walkers don't retry errors the chain
3305
+ // can't fix.
3306
+ //
3307
+ // Error shape alone cannot identify a caller cancel: NeuroLink's own
3308
+ // watchdog/timeout controllers abort the same composed signal, and SDKs
3309
+ // normalize that into the identical AbortError shapes a user cancel
3310
+ // produces (e.g. Anthropic's APIUserAbortError). Ground truth is the
3311
+ // caller-supplied abortSignal itself — only when IT has fired is an
3312
+ // abort-shaped error a real cancel; otherwise the abort was internally
3313
+ // initiated (turn/stall watchdog, per-step timeout) and the callback
3314
+ // must still be consulted so provider hangs can fall back. Read live
3315
+ // (not captured) so the post-retry re-gate sees a cancel that arrives
3316
+ // mid-fallback; cloneOptionsForCallIsolation keeps abortSignal
3317
+ // by-reference, so the retried options observe the same signal.
3318
+ const callerAborted = () => callOpts.abortSignal?.aborted === true;
3319
+ const shouldOrchestrateFallback = (err) => effectiveCallback
3320
+ ? !(isAbortError(err) && callerAborted())
3321
+ : looksLikeModelAccessDenied(err);
3305
3322
  if (!shouldOrchestrateFallback(lastError)) {
3306
3323
  throw lastError;
3307
3324
  }
@@ -5235,7 +5252,12 @@ Current user's request: ${currentInput}`;
5235
5252
  error.isRetryable ===
5236
5253
  false) ||
5237
5254
  (error instanceof Error &&
5238
- error.statusCode === 400);
5255
+ error.statusCode === 400) ||
5256
+ // A 429 already went through the provider layer's bounded,
5257
+ // Retry-After-aware retry (withProviderRetry) — re-running the
5258
+ // whole MCP generation against a rate-limited key is redundant
5259
+ // and only delays fallback orchestration.
5260
+ getErrorStatusCode(error) === 429;
5239
5261
  if (isNonRetryable) {
5240
5262
  logger.debug(`[${functionTag}] Non-retryable error detected, skipping remaining retries`);
5241
5263
  break;
@@ -608,6 +608,12 @@ export class AnthropicProvider extends BaseProvider {
608
608
  // Note: No headers passed - fetch wrapper sets oauth-2025-04-20 beta header
609
609
  fetch: oauthFetch,
610
610
  timeout: ANTHROPIC_CLIENT_TIMEOUT_MS,
611
+ // The SDK's built-in retry honors Retry-After hints without any
612
+ // upper bound (a 429 with retry-after: 8549 sleeps 2.4h per retry,
613
+ // invisible to fallback orchestration). Retries are the
614
+ // orchestrator's job; transient network blips are still retried by
615
+ // the fetch wrapper.
616
+ maxRetries: 0,
611
617
  });
612
618
  logger.debug("[AnthropicProvider] Anthropic SDK client created with OAuth fetch wrapper");
613
619
  logger.debug("Anthropic Provider initialized with OAuth", {
@@ -654,6 +660,9 @@ export class AnthropicProvider extends BaseProvider {
654
660
  ...(normalizedBaseURL && { baseURL: normalizedBaseURL }),
655
661
  fetch: createProxyFetch(),
656
662
  timeout: ANTHROPIC_CLIENT_TIMEOUT_MS,
663
+ // See the OAuth-path client above: unbounded Retry-After sleeps in
664
+ // the SDK's retry loop must never stall fallback orchestration.
665
+ maxRetries: 0,
657
666
  });
658
667
  logger.debug("Anthropic Provider initialized with API key", {
659
668
  modelName: this.modelName,
@@ -532,6 +532,10 @@ const createVertexAnthropicSettings = async (region, timeoutMs) => {
532
532
  projectId: project,
533
533
  region: location,
534
534
  ...(timeoutMs !== undefined && { timeout: timeoutMs }),
535
+ // The SDK's built-in retry honors Retry-After hints without any upper
536
+ // bound (a 429 with retry-after: 8549 sleeps 2.4h per retry, invisible
537
+ // to fallback orchestration). Retries are the orchestrator's job.
538
+ maxRetries: 0,
535
539
  };
536
540
  };
537
541
  // Helper function to determine if a model is an Anthropic model
@@ -612,6 +612,15 @@ export const buildAPIError = async (url, body, res) => {
612
612
  `OpenAI-compatible request failed with status ${res.status}`;
613
613
  const err = new Error(msg);
614
614
  err.statusCode = res.status;
615
+ // Response headers carry rate-limit hints (Retry-After, X-RateLimit-*)
616
+ // that withProviderRetry needs to honor short waits and surface long ones.
617
+ // Allowlisted to just those — attaching every header would let unrelated
618
+ // (potentially sensitive) values like set-cookie or internal routing
619
+ // headers ride along when the error is logged.
620
+ err.responseHeaders = Object.fromEntries([...res.headers.entries()].filter(([key]) => {
621
+ const lower = key.toLowerCase();
622
+ return lower === "retry-after" || lower.startsWith("x-ratelimit-");
623
+ }));
615
624
  err.url = url;
616
625
  // Redacted summary only — never attach raw prompts, tool definitions, or
617
626
  // tool arguments to the thrown error. Anything serialized by upstream