@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.
- package/CHANGELOG.md +12 -0
- package/dist/adapters/providerImageAdapter.js +35 -0
- package/dist/browser/neurolink.min.js +366 -366
- 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.d.ts +7 -1
- package/dist/lib/neurolink.js +41 -7
- package/dist/lib/providers/anthropic.js +15 -0
- package/dist/lib/providers/googleVertex.js +4 -0
- package/dist/lib/providers/openaiChatCompletionsClient.js +9 -0
- package/dist/lib/types/config.d.ts +24 -10
- package/dist/lib/types/generate.d.ts +8 -1
- package/dist/lib/types/providers.d.ts +2 -0
- package/dist/lib/types/stream.d.ts +9 -1
- package/dist/lib/utils/providerRetry.d.ts +25 -2
- package/dist/lib/utils/providerRetry.js +88 -14
- package/dist/neurolink.d.ts +7 -1
- package/dist/neurolink.js +41 -7
- package/dist/providers/anthropic.js +15 -0
- package/dist/providers/googleVertex.js +4 -0
- package/dist/providers/openaiChatCompletionsClient.js +9 -0
- package/dist/types/config.d.ts +24 -10
- package/dist/types/generate.d.ts +8 -1
- package/dist/types/providers.d.ts +2 -0
- package/dist/types/stream.d.ts +9 -1
- package/dist/utils/providerRetry.d.ts +25 -2
- package/dist/utils/providerRetry.js +88 -14
- package/package.json +3 -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.d.ts
CHANGED
|
@@ -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
|
|
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;
|
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
|
/**
|
|
@@ -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
|
|
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
|
-
|
|
3280
|
-
|
|
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 (!
|
|
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.
|
|
31
|
-
*
|
|
32
|
-
* error
|
|
33
|
-
*
|
|
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
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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`.
|
|
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`.
|
|
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
|
-
/**
|
|
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.d.ts
CHANGED
|
@@ -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
|
|
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;
|