@juspay/neurolink 10.8.1 → 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
@@ -718,7 +718,7 @@ export declare class NeuroLink {
718
718
  * Curator P2-3: wraps a generate/stream call with the fallback
719
719
  * orchestration (`providerFallback` callback + `modelChain` walker).
720
720
  *
721
- * On a model-access-denied error from the inner call:
721
+ * On a qualifying error from the inner call:
722
722
  * 1. Resolve the effective callback (per-call > instance > synthesised
723
723
  * from modelChain) and the effective chain (per-call > instance).
724
724
  * 2. Walk attempts: invoke callback (or pop next chain entry) → emit
@@ -726,6 +726,12 @@ export declare class NeuroLink {
726
726
  * model}.
727
727
  * 3. Stop on first success, on a callback returning null, or after
728
728
  * exhausting the chain (throw the most recent error).
729
+ *
730
+ * What qualifies depends on how fallback was configured: an EXPLICIT
731
+ * `providerFallback` callback is consulted for any error except client
732
+ * aborts (the callback owns the decision — it receives the error
733
+ * unmodified and can return null to bubble), while modelChain-only
734
+ * configs keep the narrow model-access-denied gate.
729
735
  */
730
736
  private runWithFallbackOrchestration;
731
737
  private attemptInner;
@@ -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
  /**
@@ -3261,7 +3262,7 @@ Current user's request: ${currentInput}`;
3261
3262
  * Curator P2-3: wraps a generate/stream call with the fallback
3262
3263
  * orchestration (`providerFallback` callback + `modelChain` walker).
3263
3264
  *
3264
- * On a model-access-denied error from the inner call:
3265
+ * On a qualifying error from the inner call:
3265
3266
  * 1. Resolve the effective callback (per-call > instance > synthesised
3266
3267
  * from modelChain) and the effective chain (per-call > instance).
3267
3268
  * 2. Walk attempts: invoke callback (or pop next chain entry) → emit
@@ -3269,6 +3270,12 @@ Current user's request: ${currentInput}`;
3269
3270
  * model}.
3270
3271
  * 3. Stop on first success, on a callback returning null, or after
3271
3272
  * exhausting the chain (throw the most recent error).
3273
+ *
3274
+ * What qualifies depends on how fallback was configured: an EXPLICIT
3275
+ * `providerFallback` callback is consulted for any error except client
3276
+ * aborts (the callback owns the decision — it receives the error
3277
+ * unmodified and can return null to bubble), while modelChain-only
3278
+ * configs keep the narrow model-access-denied gate.
3272
3279
  */
3273
3280
  async runWithFallbackOrchestration(optionsOrPrompt, kind, inner) {
3274
3281
  const initialAttempt = await this.attemptInner(inner, optionsOrPrompt);
@@ -3276,10 +3283,8 @@ Current user's request: ${currentInput}`;
3276
3283
  return initialAttempt.ok;
3277
3284
  }
3278
3285
  let lastError = initialAttempt.error;
3279
- if (!looksLikeModelAccessDenied(lastError)) {
3280
- throw lastError;
3281
- }
3282
- // Build the chain orchestration.
3286
+ // Resolve the fallback configuration BEFORE gating so the gate can
3287
+ // distinguish an explicit callback from a modelChain-only setup.
3283
3288
  const requestedProvider = (typeof optionsOrPrompt === "object"
3284
3289
  ? optionsOrPrompt.provider
3285
3290
  : undefined);
@@ -3293,6 +3298,30 @@ Current user's request: ${currentInput}`;
3293
3298
  const perCallChain = callOpts.modelChain;
3294
3299
  const effectiveCallback = perCallCallback ?? this.fallbackConfig.providerFallback;
3295
3300
  const effectiveChain = perCallChain ?? this.fallbackConfig.modelChain;
3301
+ // Explicit callback (per-call or instance providerFallback): the callback
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);
3322
+ if (!shouldOrchestrateFallback(lastError)) {
3323
+ throw lastError;
3324
+ }
3296
3325
  if (!effectiveCallback && !effectiveChain) {
3297
3326
  throw lastError;
3298
3327
  }
@@ -3364,7 +3393,7 @@ Current user's request: ${currentInput}`;
3364
3393
  }
3365
3394
  lastError = retryAttempt.error;
3366
3395
  attemptedRequestedModel = next.model ?? attemptedRequestedModel;
3367
- if (!looksLikeModelAccessDenied(lastError)) {
3396
+ if (!shouldOrchestrateFallback(lastError)) {
3368
3397
  throw lastError;
3369
3398
  }
3370
3399
  }
@@ -5223,7 +5252,12 @@ Current user's request: ${currentInput}`;
5223
5252
  error.isRetryable ===
5224
5253
  false) ||
5225
5254
  (error instanceof Error &&
5226
- 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;
5227
5261
  if (isNonRetryable) {
5228
5262
  logger.debug(`[${functionTag}] Non-retryable error detected, skipping remaining retries`);
5229
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,
@@ -724,6 +733,12 @@ export class AnthropicProvider extends BaseProvider {
724
733
  headers["anthropic-beta"] = ANTHROPIC_BETA_HEADERS["anthropic-beta"];
725
734
  }
726
735
  }
736
+ if (usingProxy) {
737
+ // WAFs in front of ANTHROPIC_BASE_URL proxies commonly block the bare
738
+ // SDK UA ("Anthropic/JS x.y.z"); send the claude-cli UA the OAuth path
739
+ // already uses. Direct-to-Anthropic traffic keeps the honest SDK UA.
740
+ headers["User-Agent"] = CLAUDE_CLI_USER_AGENT;
741
+ }
727
742
  // Add subscription-specific headers if applicable
728
743
  if (this.subscriptionTier !== "api") {
729
744
  headers["x-subscription-tier"] = this.subscriptionTier;
@@ -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
@@ -27,10 +27,17 @@ export type NeuroLinkConfig = {
27
27
  [key: string]: unknown;
28
28
  };
29
29
  /**
30
- * Curator P2-3: callback signature for centralized fallback policy. Invoked
31
- * when a generate/stream call fails with what looks like a model-access-denied
32
- * error. Return `{ provider, model }` (either / both optional) to drive a
33
- * retry; return `null` to bubble the original error untouched.
30
+ * Curator P2-3: callback signature for centralized fallback policy. When an
31
+ * explicit callback is configured (per-call or instance), it is invoked for
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.
34
41
  */
35
42
  export type ProviderFallbackCallback = (error: unknown) => Promise<{
36
43
  provider?: string;
@@ -65,16 +72,23 @@ export type NeurolinkConstructorConfig = {
65
72
  */
66
73
  credentials?: NeurolinkCredentials;
67
74
  /**
68
- * Curator P2-3: callback invoked on model-access-denied. Lets a host (e.g.
69
- * Curator) centrally drive fallback policy. The callback receives the
70
- * original error and returns the next `{ provider, model }` to try, or
75
+ * Curator P2-3: callback invoked when a generate/stream call fails with
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
71
82
  * `null` to bubble the error.
72
83
  */
73
84
  providerFallback?: ProviderFallbackCallback;
74
85
  /**
75
- * Curator P2-3: ordered list of model names to try in sequence on
76
- * model-access-denied. Sugar over `providerFallback`. The current
77
- * provider is preserved across the chain; only the model name changes.
86
+ * Curator P2-3: ordered list of model names to try in sequence. Sugar
87
+ * over `providerFallback`, but with a narrower trigger: without an
88
+ * explicit callback the chain only advances on model-access-denied
89
+ * errors — other failures (network, 5xx, timeouts) bubble immediately.
90
+ * The current provider is preserved across the chain; only the model
91
+ * name changes.
78
92
  */
79
93
  modelChain?: string[];
80
94
  /**
@@ -577,6 +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 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.
580
585
  */
581
586
  providerFallback?: (error: unknown) => Promise<{
582
587
  provider?: string;
@@ -584,7 +589,9 @@ export type GenerateOptions = {
584
589
  } | null>;
585
590
  /**
586
591
  * Curator P2-3: per-call ordered model chain. Overrides any
587
- * instance-level `modelChain`. Tried in order on model-access-denied.
592
+ * instance-level `modelChain`. Without an explicit `providerFallback`
593
+ * callback the chain only advances on model-access-denied errors —
594
+ * other failures (network, 5xx, timeouts) bubble immediately.
588
595
  */
589
596
  modelChain?: string[];
590
597
  /**
@@ -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,6 +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 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.
490
496
  */
491
497
  providerFallback?: (error: unknown) => Promise<{
492
498
  provider?: string;
@@ -494,7 +500,9 @@ export type StreamOptions = {
494
500
  } | null>;
495
501
  /**
496
502
  * Curator P2-3: per-call ordered model chain. Overrides any
497
- * instance-level `modelChain`. Tried in order on model-access-denied.
503
+ * instance-level `modelChain`. Without an explicit `providerFallback`
504
+ * callback the chain only advances on model-access-denied errors —
505
+ * other failures (network, 5xx, timeouts) bubble immediately.
498
506
  */
499
507
  modelChain?: string[];
500
508
  /**
@@ -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", {
@@ -718,7 +718,7 @@ export declare class NeuroLink {
718
718
  * Curator P2-3: wraps a generate/stream call with the fallback
719
719
  * orchestration (`providerFallback` callback + `modelChain` walker).
720
720
  *
721
- * On a model-access-denied error from the inner call:
721
+ * On a qualifying error from the inner call:
722
722
  * 1. Resolve the effective callback (per-call > instance > synthesised
723
723
  * from modelChain) and the effective chain (per-call > instance).
724
724
  * 2. Walk attempts: invoke callback (or pop next chain entry) → emit
@@ -726,6 +726,12 @@ export declare class NeuroLink {
726
726
  * model}.
727
727
  * 3. Stop on first success, on a callback returning null, or after
728
728
  * exhausting the chain (throw the most recent error).
729
+ *
730
+ * What qualifies depends on how fallback was configured: an EXPLICIT
731
+ * `providerFallback` callback is consulted for any error except client
732
+ * aborts (the callback owns the decision — it receives the error
733
+ * unmodified and can return null to bubble), while modelChain-only
734
+ * configs keep the narrow model-access-denied gate.
729
735
  */
730
736
  private runWithFallbackOrchestration;
731
737
  private attemptInner;