@juspay/neurolink 10.8.2 → 10.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/adapters/providerImageAdapter.js +35 -0
- package/dist/browser/neurolink.min.js +363 -363
- package/dist/core/baseProvider.js +25 -0
- package/dist/lib/adapters/providerImageAdapter.js +35 -0
- package/dist/lib/core/baseProvider.js +25 -0
- package/dist/lib/neurolink.js +27 -5
- package/dist/lib/providers/anthropic.js +9 -0
- package/dist/lib/providers/googleVertex.js +4 -0
- package/dist/lib/providers/openaiChatCompletionsClient.js +9 -0
- package/dist/lib/types/config.d.ts +16 -11
- package/dist/lib/types/generate.d.ts +5 -3
- package/dist/lib/types/providers.d.ts +2 -0
- package/dist/lib/types/stream.d.ts +6 -5
- package/dist/lib/utils/providerRetry.d.ts +25 -2
- package/dist/lib/utils/providerRetry.js +88 -14
- package/dist/neurolink.js +27 -5
- package/dist/providers/anthropic.js +9 -0
- package/dist/providers/googleVertex.js +4 -0
- package/dist/providers/openaiChatCompletionsClient.js +9 -0
- package/dist/types/config.d.ts +16 -11
- package/dist/types/generate.d.ts +5 -3
- package/dist/types/providers.d.ts +2 -0
- package/dist/types/stream.d.ts +6 -5
- package/dist/utils/providerRetry.d.ts +25 -2
- package/dist/utils/providerRetry.js +88 -14
- package/package.json +2 -1
|
@@ -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
|
package/dist/lib/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
|
|
3302
|
-
// null to bubble. modelChain-only keeps the narrow
|
|
3303
|
-
// gate so chain walkers don't retry errors the chain
|
|
3304
|
-
|
|
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
|
|
33
|
-
* errors, 5xx, timeouts, auth failures included.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* error
|
|
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
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
|
581
|
-
*
|
|
582
|
-
*
|
|
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
|
-
*
|
|
492
|
-
* model-access-denied,
|
|
493
|
-
*
|
|
494
|
-
*
|
|
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
|
-
/**
|
|
24
|
-
|
|
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
|
-
/**
|
|
28
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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 &&
|
|
124
|
+
if (error instanceof NeuroLinkError &&
|
|
125
|
+
error.retryAfterMs !== undefined &&
|
|
126
|
+
Number.isFinite(error.retryAfterMs)) {
|
|
72
127
|
return error.retryAfterMs;
|
|
73
128
|
}
|
|
74
|
-
return
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
3302
|
-
// null to bubble. modelChain-only keeps the narrow
|
|
3303
|
-
// gate so chain walkers don't retry errors the chain
|
|
3304
|
-
|
|
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
|