@juspay/neurolink 11.26.0 → 11.26.2
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 +3 -3
- package/dist/browser/neurolink.min.js +378 -378
- package/dist/core/baseProvider.js +16 -0
- package/dist/factories/providerDescriptors.js +16 -0
- package/dist/models/modelResolver.js +28 -6
- package/dist/providers/anthropic/client.js +5 -4
- package/dist/providers/googleAiStudio/client.js +3 -2
- package/dist/providers/googleVertex/client.js +5 -4
- package/dist/routing/classifierRouter.js +18 -4
- package/dist/types/providers.d.ts +18 -0
- package/dist/utils/drainDetachedPump.d.ts +31 -0
- package/dist/utils/drainDetachedPump.js +39 -0
- package/dist/utils/providerHealth.js +15 -20
- package/dist/utils/retryHandler.d.ts +0 -11
- package/dist/utils/retryHandler.js +6 -21
- package/package.json +3 -2
|
@@ -1733,6 +1733,22 @@ export class BaseProvider {
|
|
|
1733
1733
|
? error
|
|
1734
1734
|
: new DOMException("The operation was aborted", "AbortError");
|
|
1735
1735
|
}
|
|
1736
|
+
// Already formatted by this method — hand it straight back. Formatting is
|
|
1737
|
+
// NOT idempotent: formatProviderError prepends the provider tag every time,
|
|
1738
|
+
// so a second pass produces
|
|
1739
|
+
// "[vertex] Google Vertex AI error: [vertex] Google Vertex AI error: ..."
|
|
1740
|
+
// and, when a rule matched on statusCode the first time, can also DEGRADE
|
|
1741
|
+
// the classification (a specific "quota exhausted" ProviderError re-matching
|
|
1742
|
+
// the bare 429 rule as a generic RateLimitError) because the block below
|
|
1743
|
+
// copies statusCode onto its own output.
|
|
1744
|
+
//
|
|
1745
|
+
// A single Vertex failure reaches here FIVE times for one logical error;
|
|
1746
|
+
// this makes calls 2..5 cheap pass-throughs. `instanceof Error` rather than
|
|
1747
|
+
// a cast: nothing but an Error is ever stamped, and an unstamped value
|
|
1748
|
+
// simply falls through to formatting, which is the safe direction.
|
|
1749
|
+
if (error instanceof Error && isProviderErrorClassified(error)) {
|
|
1750
|
+
return error;
|
|
1751
|
+
}
|
|
1736
1752
|
const formatted = this.formatProviderError(error);
|
|
1737
1753
|
// Preserve transport retry metadata across formatting. Provider
|
|
1738
1754
|
// formatters return fresh Error instances (RateLimitError, NetworkError,
|
|
@@ -11,6 +11,8 @@ import { API_KEY_FORMATS } from "../utils/providerConfig.js";
|
|
|
11
11
|
export const PROVIDER_DESCRIPTORS = [
|
|
12
12
|
{
|
|
13
13
|
name: AIProviderName.BEDROCK,
|
|
14
|
+
defaultHealthSweepPriority: 5,
|
|
15
|
+
autoSelectPreference: 7,
|
|
14
16
|
aliases: ["aws"],
|
|
15
17
|
credentialsKey: "bedrock",
|
|
16
18
|
envVars: {
|
|
@@ -32,6 +34,8 @@ export const PROVIDER_DESCRIPTORS = [
|
|
|
32
34
|
},
|
|
33
35
|
{
|
|
34
36
|
name: AIProviderName.OPENAI,
|
|
37
|
+
defaultHealthSweepPriority: 4,
|
|
38
|
+
autoSelectPreference: 3,
|
|
35
39
|
aliases: ["gpt", "chatgpt"],
|
|
36
40
|
credentialsKey: "openai",
|
|
37
41
|
envVars: { apiKey: "OPENAI_API_KEY", baseURL: "OPENAI_BASE_URL" },
|
|
@@ -75,6 +79,8 @@ export const PROVIDER_DESCRIPTORS = [
|
|
|
75
79
|
},
|
|
76
80
|
{
|
|
77
81
|
name: AIProviderName.VERTEX,
|
|
82
|
+
defaultHealthSweepPriority: 1,
|
|
83
|
+
autoSelectPreference: 5,
|
|
78
84
|
aliases: ["googleVertex"],
|
|
79
85
|
credentialsKey: "vertex",
|
|
80
86
|
envVars: {
|
|
@@ -115,6 +121,8 @@ export const PROVIDER_DESCRIPTORS = [
|
|
|
115
121
|
},
|
|
116
122
|
{
|
|
117
123
|
name: AIProviderName.ANTHROPIC,
|
|
124
|
+
defaultHealthSweepPriority: 3,
|
|
125
|
+
autoSelectPreference: 4,
|
|
118
126
|
aliases: ["claude"],
|
|
119
127
|
credentialsKey: "anthropic",
|
|
120
128
|
envVars: {
|
|
@@ -137,6 +145,8 @@ export const PROVIDER_DESCRIPTORS = [
|
|
|
137
145
|
},
|
|
138
146
|
{
|
|
139
147
|
name: AIProviderName.AZURE,
|
|
148
|
+
defaultHealthSweepPriority: 6,
|
|
149
|
+
autoSelectPreference: 8,
|
|
140
150
|
aliases: ["azureOpenai"],
|
|
141
151
|
credentialsKey: "azure",
|
|
142
152
|
envVars: {
|
|
@@ -160,6 +170,8 @@ export const PROVIDER_DESCRIPTORS = [
|
|
|
160
170
|
},
|
|
161
171
|
{
|
|
162
172
|
name: AIProviderName.GOOGLE_AI,
|
|
173
|
+
defaultHealthSweepPriority: 2,
|
|
174
|
+
autoSelectPreference: 6,
|
|
163
175
|
aliases: ["googleAiStudio", "google", "gemini", "google-ai-studio"],
|
|
164
176
|
credentialsKey: "googleAiStudio",
|
|
165
177
|
envVars: {
|
|
@@ -202,6 +214,8 @@ export const PROVIDER_DESCRIPTORS = [
|
|
|
202
214
|
},
|
|
203
215
|
{
|
|
204
216
|
name: AIProviderName.OLLAMA,
|
|
217
|
+
defaultHealthSweepPriority: 8,
|
|
218
|
+
autoSelectPreference: 2,
|
|
205
219
|
aliases: ["local"],
|
|
206
220
|
credentialsKey: "ollama",
|
|
207
221
|
envVars: {
|
|
@@ -234,6 +248,8 @@ export const PROVIDER_DESCRIPTORS = [
|
|
|
234
248
|
},
|
|
235
249
|
{
|
|
236
250
|
name: AIProviderName.LITELLM,
|
|
251
|
+
defaultHealthSweepPriority: 7,
|
|
252
|
+
autoSelectPreference: 1,
|
|
237
253
|
aliases: [],
|
|
238
254
|
credentialsKey: "litellm",
|
|
239
255
|
envVars: {
|
|
@@ -5,6 +5,22 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { MODEL_REGISTRY, MODEL_ALIASES, USE_CASE_RECOMMENDATIONS, getAllModels, getModelById, getModelsByProvider, getAvailableProviders, calculateCost, formatModelForDisplay, } from "./modelRegistry.js";
|
|
7
7
|
import { isNonNullObject } from "../utils/typeUtils.js";
|
|
8
|
+
const MIN_FUZZY_QUERY_LENGTH = 4;
|
|
9
|
+
function escapeRegExp(value) {
|
|
10
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* True when `needle` appears in `haystack` at a real token boundary (hyphen,
|
|
14
|
+
* underscore, dot, slash, whitespace, or string start/end) — not merely as
|
|
15
|
+
* an arbitrary substring. Model ids use hyphens/dots ("gpt-4.1-mini");
|
|
16
|
+
* MODEL_REGISTRY .name fields use spaces ("GPT-4 Omni"), hence including
|
|
17
|
+
* \s in the boundary class.
|
|
18
|
+
*/
|
|
19
|
+
function includesAtWordBoundary(haystack, needle) {
|
|
20
|
+
const boundary = "(?:^|[-_./\\s])";
|
|
21
|
+
const pattern = new RegExp(`${boundary}${escapeRegExp(needle)}${boundary.replace("^|", "$|")}`);
|
|
22
|
+
return pattern.test(haystack);
|
|
23
|
+
}
|
|
8
24
|
/**
|
|
9
25
|
* Model resolver class with advanced search and recommendation functionality
|
|
10
26
|
*/
|
|
@@ -23,25 +39,31 @@ export class ModelResolver {
|
|
|
23
39
|
const resolvedId = MODEL_ALIASES[normalizedQuery];
|
|
24
40
|
return MODEL_REGISTRY[resolvedId] || null;
|
|
25
41
|
}
|
|
42
|
+
// Underspecified queries produce ambiguous, iteration-order-dependent
|
|
43
|
+
// matches (see Task 13 of the model metadata consolidation plan) — skip
|
|
44
|
+
// fuzzy matching entirely below this length.
|
|
45
|
+
if (normalizedQuery.length < MIN_FUZZY_QUERY_LENGTH) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
26
48
|
// Fuzzy matching
|
|
27
49
|
const allModels = getAllModels();
|
|
28
50
|
// Try partial matching on ID
|
|
29
|
-
const idMatch = allModels.find((model) => model.id.toLowerCase()
|
|
30
|
-
normalizedQuery
|
|
51
|
+
const idMatch = allModels.find((model) => includesAtWordBoundary(model.id.toLowerCase(), normalizedQuery) ||
|
|
52
|
+
includesAtWordBoundary(normalizedQuery, model.id.toLowerCase()));
|
|
31
53
|
if (idMatch) {
|
|
32
54
|
return idMatch;
|
|
33
55
|
}
|
|
34
56
|
// Try partial matching on name
|
|
35
|
-
const nameMatch = allModels.find((model) => model.name.toLowerCase()
|
|
36
|
-
normalizedQuery
|
|
57
|
+
const nameMatch = allModels.find((model) => includesAtWordBoundary(model.name.toLowerCase(), normalizedQuery) ||
|
|
58
|
+
includesAtWordBoundary(normalizedQuery, model.name.toLowerCase()));
|
|
37
59
|
if (nameMatch) {
|
|
38
60
|
return nameMatch;
|
|
39
61
|
}
|
|
40
62
|
// Try provider-specific matching
|
|
41
63
|
const providerMatch = allModels.find((model) => {
|
|
42
64
|
const providerQuery = `${model.provider}-${normalizedQuery}`;
|
|
43
|
-
return (model.id.toLowerCase()
|
|
44
|
-
model.name.toLowerCase()
|
|
65
|
+
return (includesAtWordBoundary(model.id.toLowerCase(), providerQuery) ||
|
|
66
|
+
includesAtWordBoundary(model.name.toLowerCase(), normalizedQuery));
|
|
45
67
|
});
|
|
46
68
|
if (providerMatch) {
|
|
47
69
|
return providerMatch;
|
|
@@ -15,6 +15,7 @@ import { getCapturedLimitSnapshot, getCapturedResponseHeaders, logClaudeLimitSna
|
|
|
15
15
|
import { AuthenticationError, NetworkError, ProviderError, RateLimitError, } from "../../types/index.js";
|
|
16
16
|
import { classifyProviderError } from "../../utils/errorClassifier.js";
|
|
17
17
|
import { logger } from "../../utils/logger.js";
|
|
18
|
+
import { drainDetachedPump } from "../../utils/drainDetachedPump.js";
|
|
18
19
|
import { ANTHROPIC_ELISION_NOTE, planAnthropicLoopReclaim, previewAnthropicToolResultText, } from "../../context/anthropicLoopGuard.js";
|
|
19
20
|
import { getAvailableInputTokens } from "../../constants/contextWindows.js";
|
|
20
21
|
import { estimateTokens } from "../../utils/tokenEstimation.js";
|
|
@@ -1749,15 +1750,15 @@ export class AnthropicProvider extends BaseProvider {
|
|
|
1749
1750
|
// formatted one the caller received, which is why the existing
|
|
1750
1751
|
// `loopPromise.catch` guard below does not cover it.
|
|
1751
1752
|
//
|
|
1752
|
-
//
|
|
1753
|
-
//
|
|
1754
|
-
//
|
|
1753
|
+
// Every detached-drain site in the codebase now goes through
|
|
1754
|
+
// drainDetachedPump(), which adopts the rejection and logs the reason at
|
|
1755
|
+
// debug instead of discarding it silently.
|
|
1755
1756
|
let result;
|
|
1756
1757
|
try {
|
|
1757
1758
|
result = await resultPromise;
|
|
1758
1759
|
}
|
|
1759
1760
|
catch (error) {
|
|
1760
|
-
await pump
|
|
1761
|
+
await drainDetachedPump(pump, "Anthropic");
|
|
1761
1762
|
throw error;
|
|
1762
1763
|
}
|
|
1763
1764
|
await pump;
|
|
@@ -6,6 +6,7 @@ import { ATTR, tracers, withClientSpan, withClientStreamSpan, withSpan, } from "
|
|
|
6
6
|
import { AuthenticationError, InvalidModelError, NetworkError, ProviderError, RateLimitError, } from "../../types/index.js";
|
|
7
7
|
import { ERROR_CODES, NeuroLinkError } from "../../utils/errorHandling.js";
|
|
8
8
|
import { logger } from "../../utils/logger.js";
|
|
9
|
+
import { drainDetachedPump } from "../../utils/drainDetachedPump.js";
|
|
9
10
|
import { createGeminiLoopAdapter } from "../../core/geminiLoopAdapter.js";
|
|
10
11
|
import { runAgenticLoop } from "../../core/loopEngine.js";
|
|
11
12
|
import { DEFAULT_TOOL_MAX_RETRIES } from "../../core/constants.js";
|
|
@@ -861,7 +862,7 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
861
862
|
engineResult = await resultPromise;
|
|
862
863
|
}
|
|
863
864
|
catch (error) {
|
|
864
|
-
await pump
|
|
865
|
+
await drainDetachedPump(pump, "GoogleAIStudio");
|
|
865
866
|
logger.error("[GoogleAIStudio] Native SDK error", error);
|
|
866
867
|
throw this.handleProviderError(error);
|
|
867
868
|
}
|
|
@@ -1173,7 +1174,7 @@ export class GoogleAIStudioProvider extends BaseProvider {
|
|
|
1173
1174
|
engineResult = await resultPromise;
|
|
1174
1175
|
}
|
|
1175
1176
|
catch (error) {
|
|
1176
|
-
await drain
|
|
1177
|
+
await drainDetachedPump(drain, "GoogleAIStudio");
|
|
1177
1178
|
logger.error("[GoogleAIStudio] Native SDK generate error", error);
|
|
1178
1179
|
throw this.handleProviderError(error);
|
|
1179
1180
|
}
|
|
@@ -20,6 +20,7 @@ import { applyVertexAnthropicCacheBreakpoints } from "../../utils/anthropicCache
|
|
|
20
20
|
import { FileDetector } from "../../utils/fileDetector.js";
|
|
21
21
|
import { mergeMediaFileAliases, normalizeVisionImageFormats, processUnifiedFilesArray, } from "../../utils/messageBuilder.js";
|
|
22
22
|
import { logger } from "../../utils/logger.js";
|
|
23
|
+
import { drainDetachedPump } from "../../utils/drainDetachedPump.js";
|
|
23
24
|
import { GEMINI_ELISION_NOTE, planGeminiLoopReclaim, previewGeminiToolResponseText, } from "../../context/geminiLoopGuard.js";
|
|
24
25
|
import { ANTHROPIC_ELISION_NOTE, planAnthropicLoopReclaim, previewAnthropicToolResultText, } from "../../context/anthropicLoopGuard.js";
|
|
25
26
|
import { hasRestrictedOutputLimit, RESTRICTED_OUTPUT_TOKEN_LIMIT, toVertexAnthropicModelId, } from "../../utils/modelDetection.js";
|
|
@@ -1814,7 +1815,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1814
1815
|
// rethrow the very error the branch below has already decided to absorb —
|
|
1815
1816
|
// which is what turned both turn-clock cases into failures instead of
|
|
1816
1817
|
// clean deadline exits.
|
|
1817
|
-
await pump
|
|
1818
|
+
await drainDetachedPump(pump, "GoogleVertex");
|
|
1818
1819
|
if (turnFailure !== undefined) {
|
|
1819
1820
|
// A mid-drain abort surfaces as an AbortError. End gracefully into the
|
|
1820
1821
|
// terminal block instead of re-throwing — a re-throw would route the
|
|
@@ -2598,7 +2599,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2598
2599
|
engineResult = await resultPromise;
|
|
2599
2600
|
}
|
|
2600
2601
|
catch (error) {
|
|
2601
|
-
await pump
|
|
2602
|
+
await drainDetachedPump(pump, "GoogleVertex");
|
|
2602
2603
|
// A mid-drain abort surfaces as an AbortError. End gracefully into the
|
|
2603
2604
|
// terminal block instead of re-throwing — a re-throw would route the
|
|
2604
2605
|
// caller's abort into a second unbounded fallback stream().
|
|
@@ -3687,7 +3688,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3687
3688
|
// Drained tolerantly and exactly once: when a turn ends by abort the
|
|
3688
3689
|
// channel rejects too, and re-awaiting a settled rejection would rethrow
|
|
3689
3690
|
// the error the branch below has already decided to absorb.
|
|
3690
|
-
await pump
|
|
3691
|
+
await drainDetachedPump(pump, "GoogleVertex");
|
|
3691
3692
|
if (turnFailure !== undefined) {
|
|
3692
3693
|
if (internalAbort.signal.aborted || isAbortError(turnFailure)) {
|
|
3693
3694
|
wasAborted = true;
|
|
@@ -4686,7 +4687,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
4686
4687
|
catch (error) {
|
|
4687
4688
|
turnFailure = error;
|
|
4688
4689
|
}
|
|
4689
|
-
await pump
|
|
4690
|
+
await drainDetachedPump(pump, "GoogleVertex");
|
|
4690
4691
|
if (turnFailure !== undefined) {
|
|
4691
4692
|
if (internalAbort.signal.aborted || isAbortError(turnFailure)) {
|
|
4692
4693
|
wasAborted = true;
|
|
@@ -160,7 +160,13 @@ export class ClassifierRouter {
|
|
|
160
160
|
const mode = DIFFICULTY_RANK_MODE[difficulty];
|
|
161
161
|
const originalIndex = new Map(members.map((m, i) => [m, i]));
|
|
162
162
|
const num = (v) => (typeof v === "number" ? v : NEUTRAL);
|
|
163
|
-
|
|
163
|
+
const isFullyUnmeasured = (m) => {
|
|
164
|
+
const meta = this.metaFor(m);
|
|
165
|
+
return meta.cost === undefined && meta.quality === undefined;
|
|
166
|
+
};
|
|
167
|
+
const measured = members.filter((m) => !isFullyUnmeasured(m));
|
|
168
|
+
const unmeasured = members.filter(isFullyUnmeasured);
|
|
169
|
+
const sortMeasured = (pool) => [...pool].sort((a, b) => {
|
|
164
170
|
const ma = this.metaFor(a);
|
|
165
171
|
const mb = this.metaFor(b);
|
|
166
172
|
let delta;
|
|
@@ -185,6 +191,11 @@ export class ClassifierRouter {
|
|
|
185
191
|
// Stable: preserve declared pool order on a tie.
|
|
186
192
|
return (originalIndex.get(a) ?? 0) - (originalIndex.get(b) ?? 0);
|
|
187
193
|
});
|
|
194
|
+
// The unmeasured bucket ranks after every measured member, but WITHIN the
|
|
195
|
+
// bucket the same comparator still applies: with cost/quality both
|
|
196
|
+
// NEUTRAL it falls through to the weight tie-break, preserving the
|
|
197
|
+
// documented weight contract that a plain append would silently drop.
|
|
198
|
+
return [...sortMeasured(measured), ...sortMeasured(unmeasured)];
|
|
188
199
|
}
|
|
189
200
|
/** Tool narrowing: per-difficulty directive, then classifier hints. */
|
|
190
201
|
selectTools(decision) {
|
|
@@ -223,7 +234,10 @@ export class ClassifierRouter {
|
|
|
223
234
|
if (needsEnrichment && member.model) {
|
|
224
235
|
try {
|
|
225
236
|
const info = ModelResolver.resolveModel(member.model);
|
|
226
|
-
if (info) {
|
|
237
|
+
if (!info) {
|
|
238
|
+
this.deps.logger?.debug?.(`[ClassifierRouter] metaFor: no registry match for ${member.provider}/${member.model}`);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
227
241
|
if (cost === undefined) {
|
|
228
242
|
cost = info.pricing.inputCostPer1K + info.pricing.outputCostPer1K;
|
|
229
243
|
}
|
|
@@ -251,8 +265,8 @@ export class ClassifierRouter {
|
|
|
251
265
|
}
|
|
252
266
|
}
|
|
253
267
|
}
|
|
254
|
-
catch {
|
|
255
|
-
|
|
268
|
+
catch (err) {
|
|
269
|
+
this.deps.logger?.warn?.(`[ClassifierRouter] metaFor: registry lookup threw for ${member.provider}/${member.model}`, { error: err instanceof Error ? err.message : String(err) });
|
|
256
270
|
}
|
|
257
271
|
}
|
|
258
272
|
const meta = { cost, quality, capabilities };
|
|
@@ -1885,6 +1885,24 @@ export type ProviderDescriptor = {
|
|
|
1885
1885
|
localRuntime: boolean;
|
|
1886
1886
|
/** How ProviderHealthChecker should verify this provider is reachable. */
|
|
1887
1887
|
healthCheck: "env-only" | "models-probe" | "live-generate";
|
|
1888
|
+
/**
|
|
1889
|
+
* Membership + order in the default health sweep
|
|
1890
|
+
* (`ProviderHealthChecker.checkAllProvidersHealth` with no explicit
|
|
1891
|
+
* list). Lower number = checked and reported first; the sweep's array
|
|
1892
|
+
* order is behaviour for its first-healthy fallback consumers. Absent =
|
|
1893
|
+
* not part of the default sweep. Replaces the hand-maintained 8-provider
|
|
1894
|
+
* array that lived in providerHealth.ts.
|
|
1895
|
+
*/
|
|
1896
|
+
defaultHealthSweepPriority?: number;
|
|
1897
|
+
/**
|
|
1898
|
+
* Preference rank for `getBestHealthyProvider`'s default auto-selection
|
|
1899
|
+
* (lower = tried first). Deliberately a SEPARATE ordering from the sweep:
|
|
1900
|
+
* auto-select prefers local/cheap runtimes (litellm, ollama) before cloud
|
|
1901
|
+
* providers, while the sweep reports the majors first. Absent = not in
|
|
1902
|
+
* the default preference list. Replaces the second hand-maintained array
|
|
1903
|
+
* that lived inline as getBestHealthyProvider's default parameter.
|
|
1904
|
+
*/
|
|
1905
|
+
autoSelectPreference?: number;
|
|
1888
1906
|
setupUrl?: string;
|
|
1889
1907
|
timeouts?: {
|
|
1890
1908
|
generateMs?: number;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Await a detached stream pump, swallowing its rejection but not its evidence.
|
|
3
|
+
*
|
|
4
|
+
* Several providers drain their engine's channel with a pump started outside
|
|
5
|
+
* the promise chain the caller awaits:
|
|
6
|
+
*
|
|
7
|
+
* const pump = (async () => { for await (const chunk of stream) { ... } })();
|
|
8
|
+
* const result = await resultPromise; // can throw
|
|
9
|
+
* await pump; // never reached if it does
|
|
10
|
+
*
|
|
11
|
+
* When the turn fails, `resultPromise` and the pump reject together — the
|
|
12
|
+
* engine calls `channel.error(err)` before closing — so the pump must be
|
|
13
|
+
* adopted on the error path too. Nothing adopting it is not a cosmetic leak:
|
|
14
|
+
* an unhandled rejection TERMINATES the process, so a caller who correctly
|
|
15
|
+
* try/catches the streaming error still dies. That was a real bug in the
|
|
16
|
+
* Anthropic path.
|
|
17
|
+
*
|
|
18
|
+
* The established remedy is `await pump.catch(() => {})`, which every site
|
|
19
|
+
* already uses. The gap this closes is the second half: `() => {}` throws the
|
|
20
|
+
* reason away, so the raw upstream error — the one carrying the provider's
|
|
21
|
+
* actual wire response — was invisible in traces at every one of the seven
|
|
22
|
+
* sites (six named `pump`, plus one named `drain` in googleAiStudio's
|
|
23
|
+
* non-streaming path, which a search for `pump` does not find). It is logged at DEBUG rather than WARN deliberately: on a failing turn
|
|
24
|
+
* this reason is almost always a duplicate of the error the caller is already
|
|
25
|
+
* being handed, and on an aborted turn it is the expected AbortError. It is
|
|
26
|
+
* diagnostic detail, not a new event worth alerting on.
|
|
27
|
+
*
|
|
28
|
+
* Behaviour is otherwise identical to `await pump.catch(() => {})`: it awaits,
|
|
29
|
+
* it never rethrows.
|
|
30
|
+
*/
|
|
31
|
+
export declare function drainDetachedPump(pump: Promise<unknown>, providerLabel: string): Promise<void>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { logger } from "./logger.js";
|
|
2
|
+
/**
|
|
3
|
+
* Await a detached stream pump, swallowing its rejection but not its evidence.
|
|
4
|
+
*
|
|
5
|
+
* Several providers drain their engine's channel with a pump started outside
|
|
6
|
+
* the promise chain the caller awaits:
|
|
7
|
+
*
|
|
8
|
+
* const pump = (async () => { for await (const chunk of stream) { ... } })();
|
|
9
|
+
* const result = await resultPromise; // can throw
|
|
10
|
+
* await pump; // never reached if it does
|
|
11
|
+
*
|
|
12
|
+
* When the turn fails, `resultPromise` and the pump reject together — the
|
|
13
|
+
* engine calls `channel.error(err)` before closing — so the pump must be
|
|
14
|
+
* adopted on the error path too. Nothing adopting it is not a cosmetic leak:
|
|
15
|
+
* an unhandled rejection TERMINATES the process, so a caller who correctly
|
|
16
|
+
* try/catches the streaming error still dies. That was a real bug in the
|
|
17
|
+
* Anthropic path.
|
|
18
|
+
*
|
|
19
|
+
* The established remedy is `await pump.catch(() => {})`, which every site
|
|
20
|
+
* already uses. The gap this closes is the second half: `() => {}` throws the
|
|
21
|
+
* reason away, so the raw upstream error — the one carrying the provider's
|
|
22
|
+
* actual wire response — was invisible in traces at every one of the seven
|
|
23
|
+
* sites (six named `pump`, plus one named `drain` in googleAiStudio's
|
|
24
|
+
* non-streaming path, which a search for `pump` does not find). It is logged at DEBUG rather than WARN deliberately: on a failing turn
|
|
25
|
+
* this reason is almost always a duplicate of the error the caller is already
|
|
26
|
+
* being handed, and on an aborted turn it is the expected AbortError. It is
|
|
27
|
+
* diagnostic detail, not a new event worth alerting on.
|
|
28
|
+
*
|
|
29
|
+
* Behaviour is otherwise identical to `await pump.catch(() => {})`: it awaits,
|
|
30
|
+
* it never rethrows.
|
|
31
|
+
*/
|
|
32
|
+
export async function drainDetachedPump(pump, providerLabel) {
|
|
33
|
+
try {
|
|
34
|
+
await pump;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
logger.debug(`[${providerLabel}] detached stream pump rejected; reason absorbed because the turn's own error is authoritative`, error);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -9,6 +9,7 @@ import { basename } from "path";
|
|
|
9
9
|
import { createProxyFetch } from "../proxy/proxyFetch.js";
|
|
10
10
|
import { DEFAULT_OLLAMA_MODEL } from "../providers/ollama/constants.js";
|
|
11
11
|
import { ProviderFactory } from "../factories/providerFactory.js";
|
|
12
|
+
import { PROVIDER_DESCRIPTORS } from "../factories/providerDescriptors.js";
|
|
12
13
|
export class ProviderHealthChecker {
|
|
13
14
|
static healthCache = new Map();
|
|
14
15
|
static DEFAULT_TIMEOUT = 5000; // 5 seconds
|
|
@@ -1382,16 +1383,13 @@ export class ProviderHealthChecker {
|
|
|
1382
1383
|
* Prioritizes healthy providers over configured but unhealthy ones
|
|
1383
1384
|
* Uses fast, cached health checks to avoid blocking initialization
|
|
1384
1385
|
*/
|
|
1385
|
-
static async getBestHealthyProvider(
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
"bedrock",
|
|
1393
|
-
"azure",
|
|
1394
|
-
]) {
|
|
1386
|
+
static async getBestHealthyProvider(
|
|
1387
|
+
// Auto-select preference comes from the descriptors too — a SEPARATE
|
|
1388
|
+
// ordering from the sweep (local/cheap runtimes first), carried by
|
|
1389
|
+
// autoSelectPreference rather than defaultHealthSweepPriority.
|
|
1390
|
+
preferredProviders = PROVIDER_DESCRIPTORS.filter((d) => d.autoSelectPreference !== undefined)
|
|
1391
|
+
.sort((a, b) => (a.autoSelectPreference ?? 0) - (b.autoSelectPreference ?? 0))
|
|
1392
|
+
.map((d) => d.name)) {
|
|
1395
1393
|
const healthStatuses = await this.checkAllProvidersHealth({
|
|
1396
1394
|
includeConnectivityTest: false, // Quick config check only
|
|
1397
1395
|
cacheResults: true,
|
|
@@ -1424,16 +1422,13 @@ export class ProviderHealthChecker {
|
|
|
1424
1422
|
* Get health status for all registered providers
|
|
1425
1423
|
*/
|
|
1426
1424
|
static async checkAllProvidersHealth(options = {}) {
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
AIProviderName.LITELLM,
|
|
1435
|
-
AIProviderName.OLLAMA,
|
|
1436
|
-
];
|
|
1425
|
+
// Sweep membership and ORDER come from the descriptors. Order is
|
|
1426
|
+
// behaviour: auto-select takes the first healthy provider, so the
|
|
1427
|
+
// priority field, not the descriptor array's layout, decides preference.
|
|
1428
|
+
const providers = PROVIDER_DESCRIPTORS.filter((d) => d.defaultHealthSweepPriority !== undefined)
|
|
1429
|
+
.sort((a, b) => (a.defaultHealthSweepPriority ?? 0) -
|
|
1430
|
+
(b.defaultHealthSweepPriority ?? 0))
|
|
1431
|
+
.map((d) => d.name);
|
|
1437
1432
|
const healthChecks = providers.map((provider) => this.checkProviderHealth(provider, options));
|
|
1438
1433
|
const results = await Promise.allSettled(healthChecks);
|
|
1439
1434
|
return results.map((result, index) => {
|
|
@@ -13,17 +13,6 @@ import type { RetryOptions } from "../types/index.js";
|
|
|
13
13
|
* @returns Calculated delay in milliseconds
|
|
14
14
|
*/
|
|
15
15
|
export declare function calculateBackoffDelay(attempt: number, initialDelay?: number, multiplier?: number, maxDelay?: number, addJitter?: boolean): number;
|
|
16
|
-
/**
|
|
17
|
-
* Error types that are typically retryable
|
|
18
|
-
*/
|
|
19
|
-
export declare class NetworkError extends Error {
|
|
20
|
-
readonly cause?: Error | undefined;
|
|
21
|
-
constructor(message: string, cause?: Error | undefined);
|
|
22
|
-
}
|
|
23
|
-
export declare class TemporaryError extends Error {
|
|
24
|
-
readonly cause?: Error | undefined;
|
|
25
|
-
constructor(message: string, cause?: Error | undefined);
|
|
26
|
-
}
|
|
27
16
|
/**
|
|
28
17
|
* Default retry configuration
|
|
29
18
|
*/
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { logger } from "./logger.js";
|
|
6
6
|
import { SYSTEM_LIMITS } from "../core/constants.js";
|
|
7
|
+
import { NetworkError } from "../types/index.js";
|
|
7
8
|
/**
|
|
8
9
|
* Calculate exponential backoff delay with jitter
|
|
9
10
|
* @param attempt - Current attempt number (1-based)
|
|
@@ -24,25 +25,6 @@ export function calculateBackoffDelay(attempt, initialDelay = SYSTEM_LIMITS.DEFA
|
|
|
24
25
|
: 0;
|
|
25
26
|
return cappedDelay + jitter;
|
|
26
27
|
}
|
|
27
|
-
/**
|
|
28
|
-
* Error types that are typically retryable
|
|
29
|
-
*/
|
|
30
|
-
export class NetworkError extends Error {
|
|
31
|
-
cause;
|
|
32
|
-
constructor(message, cause) {
|
|
33
|
-
super(message);
|
|
34
|
-
this.cause = cause;
|
|
35
|
-
this.name = "NetworkError";
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
export class TemporaryError extends Error {
|
|
39
|
-
cause;
|
|
40
|
-
constructor(message, cause) {
|
|
41
|
-
super(message);
|
|
42
|
-
this.cause = cause;
|
|
43
|
-
this.name = "TemporaryError";
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
28
|
/**
|
|
47
29
|
* Default retry configuration
|
|
48
30
|
*/
|
|
@@ -52,8 +34,11 @@ export const DEFAULT_RETRY_CONFIG = {
|
|
|
52
34
|
maxDelay: SYSTEM_LIMITS.DEFAULT_MAX_DELAY,
|
|
53
35
|
backoffMultiplier: SYSTEM_LIMITS.DEFAULT_BACKOFF_MULTIPLIER,
|
|
54
36
|
retryCondition: (error) => {
|
|
55
|
-
// Retry on network errors, timeouts, and specific HTTP errors
|
|
56
|
-
|
|
37
|
+
// Retry on network errors, timeouts, and specific HTTP errors. The
|
|
38
|
+
// instanceof now matches the canonical types/errors.js NetworkError —
|
|
39
|
+
// the local shadow class this file used to declare matched nothing the
|
|
40
|
+
// rest of the SDK ever threw.
|
|
41
|
+
if (error instanceof NetworkError) {
|
|
57
42
|
return true;
|
|
58
43
|
}
|
|
59
44
|
// Retry on timeout errors
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juspay/neurolink",
|
|
3
|
-
"version": "11.26.
|
|
3
|
+
"version": "11.26.2",
|
|
4
4
|
"packageManager": "pnpm@10.15.1",
|
|
5
5
|
"description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
|
|
6
6
|
"author": {
|
|
@@ -142,7 +142,7 @@
|
|
|
142
142
|
"// CI tier — fast, no live AI calls, safe for every commit (test:unit; also see the separate provider-safety-net CI job, which runs build + test:providers-mocked + test:provider-structure + test:error-classifier-contract on every PR)": "",
|
|
143
143
|
"test:tool-routing": "pnpm exec tsx test/continuous-test-suite-tool-routing.ts",
|
|
144
144
|
"test:tool-routing-semantic": "pnpm exec tsx test/continuous-test-suite-tool-routing-semantic.ts",
|
|
145
|
-
"test:unit": "pnpm run test:bugfixes && pnpm run test:mcp:infra && pnpm run test:mcp:spans && pnpm run test:tool-routing && pnpm run test:tool-routing-cli && pnpm run test:tool-dedup && pnpm run test:model-pool && pnpm run test:tool-routing-semantic && pnpm run test:mcp-result-cache && pnpm run test:model-not-found-retryable && pnpm run test:archive:security && pnpm run test:office:security && pnpm run test:vector-chroma && pnpm run test:vector-pgvector && pnpm run test:vector-pinecone && pnpm run test:provider-wiring && pnpm run test:docs-mcp",
|
|
145
|
+
"test:unit": "pnpm run test:bugfixes && pnpm run test:mcp:infra && pnpm run test:mcp:spans && pnpm run test:tool-routing && pnpm run test:tool-routing-cli && pnpm run test:tool-dedup && pnpm run test:model-pool && pnpm run test:classifier-router && pnpm run test:tool-routing-semantic && pnpm run test:mcp-result-cache && pnpm run test:model-not-found-retryable && pnpm run test:archive:security && pnpm run test:office:security && pnpm run test:vector-chroma && pnpm run test:vector-pgvector && pnpm run test:vector-pinecone && pnpm run test:provider-wiring && pnpm run test:docs-mcp",
|
|
146
146
|
"// CI tier — live providers, runs only when API keys are present (test:credentials and test:dynamic make real provider calls when keys are set, so they live here, not in test:unit; test:matrix, a different suite covering the full provider capability matrix, runs nightly via .github/workflows/live-matrix.yml — test:providers itself is still only wired into test:live, not any GitHub Actions workflow)": "",
|
|
147
147
|
"test:live": "pnpm run test:providers && pnpm run test:mcp:http && pnpm run test:mcp:sdk && pnpm run test:mcp:cli && pnpm run test:observability && pnpm run test:context && pnpm run test:memory && pnpm run test:tool-reliability && pnpm run test:evaluation && pnpm run test:autoresearch && pnpm run test:credentials && pnpm run test:dynamic",
|
|
148
148
|
"// CI tier — product output (image/video/TTS/PPT) — costs $$ per run (not wired into any GitHub Actions workflow as of this comment; run manually or add to live-matrix.yml if nightly coverage is needed)": "",
|
|
@@ -207,6 +207,7 @@
|
|
|
207
207
|
"test:archive:security": "pnpm exec tsx test/continuous-test-suite-archive-security.ts",
|
|
208
208
|
"test:office:security": "pnpm exec tsx test/continuous-test-suite-office-security.ts",
|
|
209
209
|
"test:model-pool": "pnpm exec tsx test/continuous-test-suite-model-pool.ts",
|
|
210
|
+
"test:classifier-router": "pnpm exec tsx test/continuous-test-suite-classifier-router.ts",
|
|
210
211
|
"test:vector-chroma": "pnpm exec tsx test/continuous-test-suite-vector-chroma.ts",
|
|
211
212
|
"test:vector-pgvector": "pnpm exec tsx test/continuous-test-suite-vector-pgvector.ts",
|
|
212
213
|
"test:vector-pinecone": "pnpm exec tsx test/continuous-test-suite-vector-pinecone.ts",
|