@juspay/neurolink 9.75.0 → 9.77.0
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/browser/neurolink.min.js +341 -341
- package/dist/core/baseProvider.js +29 -4
- package/dist/core/toolDedup.d.ts +51 -0
- package/dist/core/toolDedup.js +193 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +35 -0
- package/dist/lib/core/baseProvider.js +29 -4
- package/dist/lib/core/toolDedup.d.ts +51 -0
- package/dist/lib/core/toolDedup.js +194 -0
- package/dist/lib/index.d.ts +31 -0
- package/dist/lib/index.js +35 -0
- package/dist/lib/neurolink.d.ts +36 -1
- package/dist/lib/neurolink.js +387 -90
- package/dist/lib/routing/index.d.ts +7 -0
- package/dist/lib/routing/index.js +8 -0
- package/dist/lib/routing/modelPool.d.ts +83 -0
- package/dist/lib/routing/modelPool.js +243 -0
- package/dist/lib/routing/requestRouter.d.ts +30 -0
- package/dist/lib/routing/requestRouter.js +81 -0
- package/dist/lib/types/config.d.ts +32 -2
- package/dist/lib/types/index.d.ts +3 -0
- package/dist/lib/types/index.js +5 -0
- package/dist/lib/types/modelPool.d.ts +47 -0
- package/dist/lib/types/modelPool.js +11 -0
- package/dist/lib/types/requestRouter.d.ts +75 -0
- package/dist/lib/types/requestRouter.js +16 -0
- package/dist/lib/types/toolDedup.d.ts +45 -0
- package/dist/lib/types/toolDedup.js +14 -0
- package/dist/lib/utils/providerErrorClassification.d.ts +24 -0
- package/dist/lib/utils/providerErrorClassification.js +89 -0
- package/dist/neurolink.d.ts +36 -1
- package/dist/neurolink.js +387 -90
- package/dist/routing/index.d.ts +7 -0
- package/dist/routing/index.js +7 -0
- package/dist/routing/modelPool.d.ts +83 -0
- package/dist/routing/modelPool.js +242 -0
- package/dist/routing/requestRouter.d.ts +30 -0
- package/dist/routing/requestRouter.js +80 -0
- package/dist/types/config.d.ts +32 -2
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.js +5 -0
- package/dist/types/modelPool.d.ts +47 -0
- package/dist/types/modelPool.js +10 -0
- package/dist/types/requestRouter.d.ts +75 -0
- package/dist/types/requestRouter.js +15 -0
- package/dist/types/toolDedup.d.ts +45 -0
- package/dist/types/toolDedup.js +13 -0
- package/dist/utils/providerErrorClassification.d.ts +24 -0
- package/dist/utils/providerErrorClassification.js +88 -0
- package/package.json +6 -2
package/dist/neurolink.js
CHANGED
|
@@ -55,7 +55,7 @@ import { resolveDynamicArgument } from "./dynamic/dynamicResolver.js";
|
|
|
55
55
|
import { initializeHippocampus } from "./memory/hippocampusInitializer.js";
|
|
56
56
|
import { createMemoryRetrievalTools } from "./memory/memoryRetrievalTools.js";
|
|
57
57
|
import { getMetricsAggregator, MetricsAggregator, } from "./observability/metricsAggregator.js";
|
|
58
|
-
import { SpanStatus, SpanType, CircuitBreakerOpenError, ConversationMemoryError,
|
|
58
|
+
import { SpanStatus, SpanType, CircuitBreakerOpenError, ConversationMemoryError, ModelAccessDeniedError, } from "./types/index.js";
|
|
59
59
|
import { SpanSerializer } from "./observability/utils/spanSerializer.js";
|
|
60
60
|
import { flushOpenTelemetry, getLangfuseContext, getLangfuseHealthStatus, initializeOpenTelemetry, isOpenTelemetryInitialized, runWithCurrentLangfuseContext, setLangfuseContext, shutdownOpenTelemetry, stampGuestRescueIdentity, } from "./services/server/ai/observability/instrumentation.js";
|
|
61
61
|
import { TaskManager } from "./tasks/taskManager.js";
|
|
@@ -78,7 +78,6 @@ import { resolveModel } from "./utils/modelAliasResolver.js";
|
|
|
78
78
|
// Import orchestration components
|
|
79
79
|
import { ModelRouter } from "./utils/modelRouter.js";
|
|
80
80
|
import { getBestProvider } from "./utils/providerUtils.js";
|
|
81
|
-
import { NON_RETRYABLE_HTTP_STATUS_CODES, isDeterministicClientErrorMessage, } from "./utils/retryability.js";
|
|
82
81
|
import { isZodSchema } from "./utils/schemaConversion.js";
|
|
83
82
|
import { BinaryTaskClassifier } from "./utils/taskClassifier.js";
|
|
84
83
|
// Tool detection and execution imports
|
|
@@ -87,6 +86,8 @@ import { extractToolNames, optimizeToolForCollection, transformAvailableTools, t
|
|
|
87
86
|
import { isNonNullObject } from "./utils/typeUtils.js";
|
|
88
87
|
import { getWorkflow } from "./workflow/core/workflowRegistry.js";
|
|
89
88
|
import { runWorkflow } from "./workflow/core/workflowRunner.js";
|
|
89
|
+
import { ModelPool, classifyProviderError } from "./routing/index.js";
|
|
90
|
+
import { looksLikeModelAccessDenied as sharedLooksLikeModelAccessDenied, isNonRetryableProviderError as sharedIsNonRetryableProviderError, } from "./utils/providerErrorClassification.js";
|
|
90
91
|
/**
|
|
91
92
|
* NL-002: Classify MCP error messages into categories for AI disambiguation.
|
|
92
93
|
* Returns a human-readable error category based on error message content.
|
|
@@ -151,95 +152,12 @@ function mcpCategoryToErrorCategory(mcpCategory) {
|
|
|
151
152
|
* This prevents wasting tokens and latency on guaranteed-to-fail retries.
|
|
152
153
|
* For example, a NOT_FOUND error for a model causes 6 retries of a 418KB
|
|
153
154
|
* message, wasting ~628,000 tokens and adding 10+ seconds of latency.
|
|
155
|
+
*
|
|
156
|
+
* Delegates to the shared utility in utils/providerErrorClassification.ts so
|
|
157
|
+
* that modelPool.ts (classifyProviderError) and this function stay in sync.
|
|
154
158
|
*/
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
* ModelAccessDeniedError class to be present (Issue #1 ships that class
|
|
158
|
-
* separately). Matches LiteLLM "team not allowed" / "team can only access
|
|
159
|
-
* models=[...]" plus typed-error markers when present.
|
|
160
|
-
*/
|
|
161
|
-
function looksLikeModelAccessDenied(error) {
|
|
162
|
-
if (!error) {
|
|
163
|
-
return false;
|
|
164
|
-
}
|
|
165
|
-
const e = error;
|
|
166
|
-
if (e.name === "ModelAccessDeniedError") {
|
|
167
|
-
return true;
|
|
168
|
-
}
|
|
169
|
-
if (e.code === "MODEL_ACCESS_DENIED") {
|
|
170
|
-
return true;
|
|
171
|
-
}
|
|
172
|
-
const msg = typeof e.message === "string"
|
|
173
|
-
? e.message
|
|
174
|
-
: error instanceof Error
|
|
175
|
-
? error.message
|
|
176
|
-
: String(error);
|
|
177
|
-
if (!msg) {
|
|
178
|
-
return false;
|
|
179
|
-
}
|
|
180
|
-
const lower = msg.toLowerCase();
|
|
181
|
-
return ((lower.includes("team") && lower.includes("not allowed")) ||
|
|
182
|
-
lower.includes("team can only access") ||
|
|
183
|
-
/not\s+allowed\s+to\s+access\s+(this\s+)?model/i.test(msg));
|
|
184
|
-
}
|
|
185
|
-
function isNonRetryableProviderError(error) {
|
|
186
|
-
// Check for typed error classes from providers
|
|
187
|
-
if (error instanceof InvalidModelError) {
|
|
188
|
-
return true;
|
|
189
|
-
}
|
|
190
|
-
if (error instanceof AuthenticationError) {
|
|
191
|
-
return true;
|
|
192
|
-
}
|
|
193
|
-
if (error instanceof AuthorizationError) {
|
|
194
|
-
return true;
|
|
195
|
-
}
|
|
196
|
-
// Curator P1-1: model-access-denied is permanent for the (provider, model)
|
|
197
|
-
// pair until the team whitelist changes. Retrying with the same config
|
|
198
|
-
// would just waste a second roundtrip. Caller / fallback-orchestrator
|
|
199
|
-
// should pick a different model.
|
|
200
|
-
if (error instanceof ModelAccessDeniedError) {
|
|
201
|
-
return true;
|
|
202
|
-
}
|
|
203
|
-
// Note: ContextBudgetExceededError is intentionally NOT non-retryable.
|
|
204
|
-
// Each provider has its own context window, so a budget rejection on
|
|
205
|
-
// one provider doesn't preclude another provider's window fitting the
|
|
206
|
-
// same payload. The directProviderGeneration loop should continue
|
|
207
|
-
// trying alternate providers; the after-loop rethrow preserves the
|
|
208
|
-
// typed error when all providers reject (see `directProviderGeneration`).
|
|
209
|
-
// Check for HTTP status codes on error objects (e.g., from Vercel AI SDK)
|
|
210
|
-
if (error && typeof error === "object") {
|
|
211
|
-
const err = error;
|
|
212
|
-
const status = typeof err.status === "number"
|
|
213
|
-
? err.status
|
|
214
|
-
: typeof err.statusCode === "number"
|
|
215
|
-
? err.statusCode
|
|
216
|
-
: undefined;
|
|
217
|
-
if (status && NON_RETRYABLE_HTTP_STATUS_CODES.includes(status)) {
|
|
218
|
-
return true;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
// Check error message for NOT_FOUND patterns (catches wrapped errors)
|
|
222
|
-
if (error instanceof Error) {
|
|
223
|
-
const msg = error.message;
|
|
224
|
-
if (msg.includes("NOT_FOUND") ||
|
|
225
|
-
msg.includes("Model Not Found") ||
|
|
226
|
-
msg.includes("model not found") ||
|
|
227
|
-
msg.includes("PERMISSION_DENIED") ||
|
|
228
|
-
msg.includes("UNAUTHENTICATED")) {
|
|
229
|
-
return true;
|
|
230
|
-
}
|
|
231
|
-
// A deterministic 400 / malformed-request whose status is only present in
|
|
232
|
-
// the message string (e.g. Vertex wraps `{"code":400,"status":
|
|
233
|
-
// "INVALID_ARGUMENT"}` inside the message). The object-level status check
|
|
234
|
-
// above misses it, so without this the fallback orchestrator retries the
|
|
235
|
-
// identical bad payload on every other provider — they reject it the same
|
|
236
|
-
// way. The request itself is malformed, so abort fast.
|
|
237
|
-
if (isDeterministicClientErrorMessage(msg)) {
|
|
238
|
-
return true;
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
return false;
|
|
242
|
-
}
|
|
159
|
+
const looksLikeModelAccessDenied = sharedLooksLikeModelAccessDenied;
|
|
160
|
+
const isNonRetryableProviderError = sharedIsNonRetryableProviderError;
|
|
243
161
|
/**
|
|
244
162
|
* NeuroLink - Universal AI Development Platform
|
|
245
163
|
*
|
|
@@ -445,6 +363,8 @@ export class NeuroLink {
|
|
|
445
363
|
// Lazy-initialized routing decision cache (ITEM C). Created on first use so
|
|
446
364
|
// instances that don't use routing pay no overhead.
|
|
447
365
|
toolRoutingCacheInstance;
|
|
366
|
+
// Opt-in tool-signature deduplication config.
|
|
367
|
+
toolDedupConfig;
|
|
448
368
|
// Add orchestration property
|
|
449
369
|
enableOrchestration;
|
|
450
370
|
// Authentication provider for secure access control
|
|
@@ -456,6 +376,12 @@ export class NeuroLink {
|
|
|
456
376
|
// Curator P2-3: instance-level fallback policy. Read by
|
|
457
377
|
// runWithFallbackOrchestration on model-access-denied.
|
|
458
378
|
fallbackConfig = {};
|
|
379
|
+
// ModelPool: opt-in multi-provider failover with per-member cooldown.
|
|
380
|
+
// Built once from config.modelPool in the constructor; null when not configured.
|
|
381
|
+
modelPool;
|
|
382
|
+
// RequestRouter: pluggable pre-call provider/model selector.
|
|
383
|
+
// Stored directly from config.requestRouter; null when not configured.
|
|
384
|
+
requestRouter;
|
|
459
385
|
/**
|
|
460
386
|
* Merge instance-level credentials with per-call credentials.
|
|
461
387
|
*
|
|
@@ -857,6 +783,13 @@ export class NeuroLink {
|
|
|
857
783
|
// multiple NeuroLink instances.
|
|
858
784
|
this.toolRoutingConfig = { ...config.toolRouting };
|
|
859
785
|
}
|
|
786
|
+
if (config?.toolDedup) {
|
|
787
|
+
this.toolDedupConfig = { ...config.toolDedup };
|
|
788
|
+
}
|
|
789
|
+
// ModelPool: build one instance from config; null when not configured.
|
|
790
|
+
this.modelPool = config?.modelPool ? new ModelPool(config.modelPool) : null;
|
|
791
|
+
// RequestRouter: store the host-supplied function; null when not configured.
|
|
792
|
+
this.requestRouter = config?.requestRouter ?? null;
|
|
860
793
|
logger.setEventEmitter(this.emitter);
|
|
861
794
|
// Read tool cache duration from environment variables, with a default
|
|
862
795
|
const cacheDurationEnv = process.env.NEUROLINK_TOOL_CACHE_DURATION;
|
|
@@ -3167,6 +3100,11 @@ Current user's request: ${currentInput}`;
|
|
|
3167
3100
|
async runStandardGenerateRequest(options, originalPrompt, generateSpan) {
|
|
3168
3101
|
const startTime = Date.now();
|
|
3169
3102
|
await this.maybeApplyGenerateOrchestration(options);
|
|
3103
|
+
// Pre-call request router: opt-in, only when the caller did not set both
|
|
3104
|
+
// provider+model. Fails open (any router error leaves options unchanged).
|
|
3105
|
+
await this.applyRequestRouter(options, options.input?.text ?? originalPrompt ?? "", !options.disableTools &&
|
|
3106
|
+
(!!(options.tools && Object.keys(options.tools).length > 0) ||
|
|
3107
|
+
this.getCustomTools().size > 0), !!(options.input?.images && options.input.images.length > 0), options.thinkingConfig?.thinkingLevel);
|
|
3170
3108
|
this.emitter.emit("generation:start", {
|
|
3171
3109
|
provider: options.provider || "auto",
|
|
3172
3110
|
timestamp: startTime,
|
|
@@ -3275,6 +3213,94 @@ Current user's request: ${currentInput}`;
|
|
|
3275
3213
|
});
|
|
3276
3214
|
}
|
|
3277
3215
|
}
|
|
3216
|
+
/**
|
|
3217
|
+
* Applies the host-configured `requestRouter` to `options` in place.
|
|
3218
|
+
*
|
|
3219
|
+
* The router is skipped when:
|
|
3220
|
+
* - no `requestRouter` is configured on this instance, or
|
|
3221
|
+
* - the caller explicitly set both `options.provider` AND `options.model`
|
|
3222
|
+
* (we only skip the router if BOTH are set; a caller setting only one
|
|
3223
|
+
* still lets the router fill in the other field).
|
|
3224
|
+
*
|
|
3225
|
+
* Fails open: any router error is logged at WARN level and the call
|
|
3226
|
+
* continues with the original options unmodified.
|
|
3227
|
+
*
|
|
3228
|
+
* @param options — the mutable options object (generate or stream).
|
|
3229
|
+
* @param promptText — the text prompt used to build the RouterInputContext.
|
|
3230
|
+
* @param hasTools — true when at least one tool is available for this call.
|
|
3231
|
+
* @param requiresVision — true when the call includes image attachments.
|
|
3232
|
+
* @param thinkingLevel — optional thinking level string from the call options.
|
|
3233
|
+
*/
|
|
3234
|
+
async applyRequestRouter(options, promptText, hasTools, requiresVision, thinkingLevel) {
|
|
3235
|
+
if (!this.requestRouter) {
|
|
3236
|
+
return;
|
|
3237
|
+
}
|
|
3238
|
+
// Skip if the caller explicitly set both provider AND model — they know
|
|
3239
|
+
// what they want; the router should not override an intentional choice.
|
|
3240
|
+
if (options.provider && options.model) {
|
|
3241
|
+
return;
|
|
3242
|
+
}
|
|
3243
|
+
// Skip when a ModelPool is also configured: the pool unconditionally
|
|
3244
|
+
// overrides provider+model for each member it selects. Allowing the
|
|
3245
|
+
// router to run first would set options.provider/model to a value the
|
|
3246
|
+
// pool immediately clobbers, creating silent ownership confusion and
|
|
3247
|
+
// making the pool bypass the "user didn't set provider+model" guard above
|
|
3248
|
+
// on subsequent attempts.
|
|
3249
|
+
if (this.modelPool) {
|
|
3250
|
+
logger.debug("[NeuroLink] applyRequestRouter: skipped — modelPool takes precedence");
|
|
3251
|
+
return;
|
|
3252
|
+
}
|
|
3253
|
+
const ctx = {
|
|
3254
|
+
prompt: promptText,
|
|
3255
|
+
// Cheap token estimate: ~4 chars/token.
|
|
3256
|
+
estimatedInputTokens: Math.ceil(promptText.length / 4),
|
|
3257
|
+
hasTools,
|
|
3258
|
+
requiresVision,
|
|
3259
|
+
thinkingLevel,
|
|
3260
|
+
};
|
|
3261
|
+
try {
|
|
3262
|
+
const decision = await this.requestRouter(ctx);
|
|
3263
|
+
// Apply provider/model/region as a coherent set: when the router supplies
|
|
3264
|
+
// a provider (the caller didn't pin one), take its model and region too.
|
|
3265
|
+
// If the caller pinned the provider but not the model, only apply the
|
|
3266
|
+
// router's model when it targets the same provider.
|
|
3267
|
+
if (decision.provider && !options.provider) {
|
|
3268
|
+
options.provider = decision.provider;
|
|
3269
|
+
// Adopt the router's model and region only when they belong to the
|
|
3270
|
+
// provider the router just selected — never mix a router model onto a
|
|
3271
|
+
// caller-pinned different provider.
|
|
3272
|
+
if (decision.model && !options.model) {
|
|
3273
|
+
options.model = decision.model;
|
|
3274
|
+
}
|
|
3275
|
+
if (decision.region && !options.region) {
|
|
3276
|
+
options.region = decision.region;
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
else if (!decision.provider) {
|
|
3280
|
+
// Router returned a no-op or model-only decision.
|
|
3281
|
+
if (decision.model && !options.model) {
|
|
3282
|
+
options.model = decision.model;
|
|
3283
|
+
}
|
|
3284
|
+
if (decision.region && !options.region) {
|
|
3285
|
+
options.region = decision.region;
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
// If decision.provider differs from the caller-pinned options.provider,
|
|
3289
|
+
// we skip the router result entirely to avoid an incoherent pairing.
|
|
3290
|
+
if (decision.reason) {
|
|
3291
|
+
logger.debug("[NeuroLink] requestRouter decision", {
|
|
3292
|
+
reason: decision.reason,
|
|
3293
|
+
provider: options.provider,
|
|
3294
|
+
model: options.model,
|
|
3295
|
+
});
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
catch (routerErr) {
|
|
3299
|
+
logger.warn("[NeuroLink] requestRouter threw — proceeding unrouted", {
|
|
3300
|
+
error: routerErr instanceof Error ? routerErr.message : String(routerErr),
|
|
3301
|
+
});
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
3278
3304
|
async prepareGenerateAugmentations(options) {
|
|
3279
3305
|
if (options.rag?.files?.length) {
|
|
3280
3306
|
try {
|
|
@@ -4956,6 +4982,149 @@ Current user's request: ${currentInput}`;
|
|
|
4956
4982
|
tryProviders,
|
|
4957
4983
|
allowFallback: !requestedProvider || !!preferredOrchestrated,
|
|
4958
4984
|
});
|
|
4985
|
+
// ─── ModelPool path ──────────────────────────────────────────────────────
|
|
4986
|
+
// When a ModelPool is configured, source the candidate sequence from the
|
|
4987
|
+
// pool instead of (or in addition to) the static tryProviders list.
|
|
4988
|
+
// Preserves the existing isNonRetryableProviderError short-circuit and
|
|
4989
|
+
// AbortError propagation semantics. Falls through to the standard path
|
|
4990
|
+
// when no pool is configured.
|
|
4991
|
+
if (this.modelPool) {
|
|
4992
|
+
const pool = this.modelPool;
|
|
4993
|
+
const maxPoolAttempts = pool.maxAttempts;
|
|
4994
|
+
const triedKeys = new Set();
|
|
4995
|
+
let poolLastError = null;
|
|
4996
|
+
for (let attempt = 0; attempt < maxPoolAttempts; attempt++) {
|
|
4997
|
+
if (options.abortSignal?.aborted) {
|
|
4998
|
+
throw new DOMException("The operation was aborted", "AbortError");
|
|
4999
|
+
}
|
|
5000
|
+
const member = pool.selectNext(triedKeys);
|
|
5001
|
+
if (!member) {
|
|
5002
|
+
// All available members exhausted.
|
|
5003
|
+
break;
|
|
5004
|
+
}
|
|
5005
|
+
triedKeys.add(pool.memberKey(member));
|
|
5006
|
+
// Temporarily override provider/model/region from the pool member.
|
|
5007
|
+
const savedProvider = options.provider;
|
|
5008
|
+
const savedModel = options.model;
|
|
5009
|
+
const savedRegion = options.region;
|
|
5010
|
+
options.provider = member.provider;
|
|
5011
|
+
// When the member omits model/region, clear inherited values so the
|
|
5012
|
+
// provider's own default is used rather than a mismatched caller value.
|
|
5013
|
+
options.model = member.model ?? undefined;
|
|
5014
|
+
options.region = member.region ?? undefined;
|
|
5015
|
+
const poolProviderName = member.provider;
|
|
5016
|
+
logger.debug(`[${functionTag}] ModelPool: attempting member`, {
|
|
5017
|
+
provider: poolProviderName,
|
|
5018
|
+
model: member.model ?? options.model,
|
|
5019
|
+
attempt,
|
|
5020
|
+
});
|
|
5021
|
+
try {
|
|
5022
|
+
// Get conversation messages for context (use pre-compacted if provided)
|
|
5023
|
+
const poolOptionsWithMessages = options;
|
|
5024
|
+
const poolConversationMessages = poolOptionsWithMessages
|
|
5025
|
+
.conversationMessages?.length
|
|
5026
|
+
? poolOptionsWithMessages.conversationMessages
|
|
5027
|
+
: await getConversationMessages(this.conversationMemory, options);
|
|
5028
|
+
const poolProvider = await AIProviderFactory.createProvider(poolProviderName, options.model, !options.disableTools, this, options.region, this.resolveCredentials(options.credentials));
|
|
5029
|
+
poolProvider.setTraceContext(this._metricsTraceContext);
|
|
5030
|
+
poolProvider.setupToolExecutor({
|
|
5031
|
+
customTools: this.getCustomTools(),
|
|
5032
|
+
executeTool: (toolName, params) => this.executeTool(toolName, params, {
|
|
5033
|
+
disableToolCache: options.disableToolCache,
|
|
5034
|
+
}),
|
|
5035
|
+
}, functionTag);
|
|
5036
|
+
const poolResult = await poolProvider.generate({
|
|
5037
|
+
...options,
|
|
5038
|
+
conversationMessages: poolConversationMessages,
|
|
5039
|
+
});
|
|
5040
|
+
if (!poolResult) {
|
|
5041
|
+
throw new Error(`ModelPool: provider ${poolProviderName} returned null result`);
|
|
5042
|
+
}
|
|
5043
|
+
pool.recordSuccess(member);
|
|
5044
|
+
const responseTime = Date.now() - startTime;
|
|
5045
|
+
// Restore original options fields so callers see a clean object.
|
|
5046
|
+
options.provider = savedProvider;
|
|
5047
|
+
options.model = savedModel;
|
|
5048
|
+
options.region = savedRegion;
|
|
5049
|
+
return {
|
|
5050
|
+
content: poolResult.content || "",
|
|
5051
|
+
provider: poolProviderName,
|
|
5052
|
+
model: poolResult.model,
|
|
5053
|
+
usage: poolResult.usage,
|
|
5054
|
+
responseTime,
|
|
5055
|
+
finishReason: poolResult.finishReason,
|
|
5056
|
+
toolsUsed: poolResult.toolsUsed || [],
|
|
5057
|
+
toolExecutions: poolResult.toolExecutions?.map((te) => {
|
|
5058
|
+
const t = te;
|
|
5059
|
+
return {
|
|
5060
|
+
...te,
|
|
5061
|
+
toolName: te.name,
|
|
5062
|
+
executionTime: typeof t.executionTime === "number"
|
|
5063
|
+
? t.executionTime
|
|
5064
|
+
: typeof t.duration === "number"
|
|
5065
|
+
? t.duration
|
|
5066
|
+
: 0,
|
|
5067
|
+
success: typeof t.success === "boolean"
|
|
5068
|
+
? t.success
|
|
5069
|
+
: t.status === "success",
|
|
5070
|
+
};
|
|
5071
|
+
}),
|
|
5072
|
+
enhancedWithTools: !!poolResult.toolExecutions?.length,
|
|
5073
|
+
analytics: poolResult.analytics,
|
|
5074
|
+
evaluation: poolResult.evaluation,
|
|
5075
|
+
audio: poolResult.audio,
|
|
5076
|
+
video: poolResult.video,
|
|
5077
|
+
avatar: poolResult.avatar,
|
|
5078
|
+
music: poolResult.music,
|
|
5079
|
+
ppt: poolResult.ppt,
|
|
5080
|
+
imageOutput: poolResult.imageOutput,
|
|
5081
|
+
reasoning: poolResult.reasoning,
|
|
5082
|
+
reasoningTokens: poolResult.reasoningTokens,
|
|
5083
|
+
_generationEndEmitted: poolResult._generationEndEmitted,
|
|
5084
|
+
};
|
|
5085
|
+
}
|
|
5086
|
+
catch (poolError) {
|
|
5087
|
+
// Restore options unconditionally on failure.
|
|
5088
|
+
options.provider = savedProvider;
|
|
5089
|
+
options.model = savedModel;
|
|
5090
|
+
options.region = savedRegion;
|
|
5091
|
+
if (isAbortError(poolError)) {
|
|
5092
|
+
throw poolError;
|
|
5093
|
+
}
|
|
5094
|
+
if (isNonRetryableProviderError(poolError)) {
|
|
5095
|
+
const poolErrMsg = poolError instanceof Error
|
|
5096
|
+
? poolError.message
|
|
5097
|
+
: String(poolError);
|
|
5098
|
+
logger.warn(`[${functionTag}] ModelPool: non-retryable error from ${poolProviderName}, stopping pool`, { error: poolErrMsg });
|
|
5099
|
+
pool.recordFailure(member, classifyProviderError(poolError));
|
|
5100
|
+
// Wrap in a plain Error so runWithFallbackOrchestration does not
|
|
5101
|
+
// mistake this for a ModelAccessDeniedError and start a second
|
|
5102
|
+
// retry layer on top of the already-exhausted pool.
|
|
5103
|
+
throw new Error(`[ModelPool] non-retryable: ${poolErrMsg}`, {
|
|
5104
|
+
cause: poolError,
|
|
5105
|
+
});
|
|
5106
|
+
}
|
|
5107
|
+
pool.recordFailure(member, classifyProviderError(poolError));
|
|
5108
|
+
poolLastError =
|
|
5109
|
+
poolError instanceof Error
|
|
5110
|
+
? poolError
|
|
5111
|
+
: new Error(String(poolError));
|
|
5112
|
+
logger.warn(`[${functionTag}] ModelPool: member ${poolProviderName} failed`, { error: poolLastError.message });
|
|
5113
|
+
}
|
|
5114
|
+
}
|
|
5115
|
+
// All pool members failed or were exhausted.
|
|
5116
|
+
const poolResponseTime = Date.now() - startTime;
|
|
5117
|
+
logger.error(`[${functionTag}] ModelPool: all members failed`, {
|
|
5118
|
+
triedKeys: Array.from(triedKeys),
|
|
5119
|
+
lastError: poolLastError?.message,
|
|
5120
|
+
responseTime: poolResponseTime,
|
|
5121
|
+
});
|
|
5122
|
+
// Wrap in a plain Error (not a typed error class) so that
|
|
5123
|
+
// runWithFallbackOrchestration does not re-activate the modelChain /
|
|
5124
|
+
// providerFallback layer on top of an already-exhausted pool.
|
|
5125
|
+
throw new Error(`[ModelPool] all members failed: ${poolLastError?.message ?? "no members available"}`);
|
|
5126
|
+
}
|
|
5127
|
+
// ─── End ModelPool path ───────────────────────────────────────────────────
|
|
4959
5128
|
let lastError = null;
|
|
4960
5129
|
// Try each provider in order
|
|
4961
5130
|
for (const providerName of tryProviders) {
|
|
@@ -5654,6 +5823,11 @@ Current user's request: ${currentInput}`;
|
|
|
5654
5823
|
// classify correctly. After the workflow short-circuit, so workflow
|
|
5655
5824
|
// streams skip it.
|
|
5656
5825
|
await context.with(streamSpanContext, () => this.setLangfuseContextFromOptions(options, () => this.applyToolRoutingExclusions(options, originalPrompt)));
|
|
5826
|
+
// Pre-call request router for stream: opt-in, fails open.
|
|
5827
|
+
await this.applyRequestRouter(options, originalPrompt, !options.disableTools &&
|
|
5828
|
+
(!!(options.tools && Object.keys(options.tools).length > 0) ||
|
|
5829
|
+
this.getCustomTools().size > 0), !!(options.input?.images && options.input.images.length > 0), options
|
|
5830
|
+
.thinking?.thinkingLevel);
|
|
5657
5831
|
// TTS Mode 2 deferred: stream() emits text first, then synthesizes the
|
|
5658
5832
|
// accumulated response into a single audio chunk at end-of-stream and
|
|
5659
5833
|
// resolves `streamResult.audio` with the same TTSResult. The resolver is
|
|
@@ -7114,6 +7288,114 @@ Current user's request: ${currentInput}`;
|
|
|
7114
7288
|
}
|
|
7115
7289
|
}
|
|
7116
7290
|
}
|
|
7291
|
+
// ─── ModelPool stream path ────────────────────────────────────────────────
|
|
7292
|
+
// When a pool is configured, replaces the single provider.stream() call
|
|
7293
|
+
// with a retry loop over pool members. Compaction/budget-check above ran
|
|
7294
|
+
// once on the initially-selected `providerName`; the pool may redirect to
|
|
7295
|
+
// a different provider, but those checks are conservative enough that the
|
|
7296
|
+
// redirected provider will also accept the payload (if it has a smaller
|
|
7297
|
+
// window, the budget check would have already thrown before we get here).
|
|
7298
|
+
// On exhaustion, throws the last member error — does NOT fall through to
|
|
7299
|
+
// the static provider path below.
|
|
7300
|
+
if (this.modelPool) {
|
|
7301
|
+
const streamPool = this.modelPool;
|
|
7302
|
+
const maxStreamAttempts = streamPool.maxAttempts;
|
|
7303
|
+
const streamTriedKeys = new Set();
|
|
7304
|
+
let streamLastError = null;
|
|
7305
|
+
for (let attempt = 0; attempt < maxStreamAttempts; attempt++) {
|
|
7306
|
+
if (options.abortSignal?.aborted) {
|
|
7307
|
+
throw new DOMException("The operation was aborted", "AbortError");
|
|
7308
|
+
}
|
|
7309
|
+
const streamMember = streamPool.selectNext(streamTriedKeys);
|
|
7310
|
+
if (!streamMember) {
|
|
7311
|
+
break;
|
|
7312
|
+
}
|
|
7313
|
+
streamTriedKeys.add(streamPool.memberKey(streamMember));
|
|
7314
|
+
// Override provider/model/region from the pool member.
|
|
7315
|
+
// When the member omits model/region, use undefined (provider default)
|
|
7316
|
+
// rather than inheriting the caller's value for a different provider.
|
|
7317
|
+
const poolStreamProviderName = streamMember.provider;
|
|
7318
|
+
const poolStreamModel = streamMember.model ?? undefined;
|
|
7319
|
+
const poolStreamRegion = streamMember.region ?? undefined;
|
|
7320
|
+
logger.debug(`[createMCPStream] ModelPool: attempting member ${poolStreamProviderName}`, { model: poolStreamModel, attempt });
|
|
7321
|
+
try {
|
|
7322
|
+
const poolStreamProvider = await AIProviderFactory.createProvider(poolStreamProviderName, poolStreamModel, !options.disableTools, this, poolStreamRegion, this.resolveCredentials(options.credentials));
|
|
7323
|
+
poolStreamProvider.setTraceContext(this._metricsTraceContext);
|
|
7324
|
+
poolStreamProvider.setupToolExecutor({
|
|
7325
|
+
customTools: this.getCustomTools(),
|
|
7326
|
+
executeTool: (toolName, params) => this.executeTool(toolName, params, {
|
|
7327
|
+
disableToolCache: options.disableToolCache,
|
|
7328
|
+
}),
|
|
7329
|
+
}, "NeuroLink.createMCPStream");
|
|
7330
|
+
const poolStreamResult = await poolStreamProvider.stream({
|
|
7331
|
+
...options,
|
|
7332
|
+
provider: poolStreamProviderName,
|
|
7333
|
+
model: poolStreamModel,
|
|
7334
|
+
region: poolStreamRegion,
|
|
7335
|
+
systemPrompt: enhancedSystemPrompt,
|
|
7336
|
+
conversationMessages,
|
|
7337
|
+
});
|
|
7338
|
+
logger.debug("[createMCPStream] ModelPool stream handle obtained", {
|
|
7339
|
+
provider: poolStreamProviderName,
|
|
7340
|
+
});
|
|
7341
|
+
// Wrap the stream so we can record success/failure based on what
|
|
7342
|
+
// actually happens during consumption, not merely on obtaining the
|
|
7343
|
+
// handle. Recording success at handle-acquisition time (before any
|
|
7344
|
+
// tokens are delivered) would mean a mid-stream provider drop is
|
|
7345
|
+
// never reflected as a cooldown.
|
|
7346
|
+
const capturedMember = streamMember;
|
|
7347
|
+
const wrappedStream = (async function* () {
|
|
7348
|
+
try {
|
|
7349
|
+
yield* poolStreamResult.stream;
|
|
7350
|
+
streamPool.recordSuccess(capturedMember);
|
|
7351
|
+
}
|
|
7352
|
+
catch (streamConsumeErr) {
|
|
7353
|
+
streamPool.recordFailure(capturedMember, classifyProviderError(streamConsumeErr));
|
|
7354
|
+
throw streamConsumeErr;
|
|
7355
|
+
}
|
|
7356
|
+
})();
|
|
7357
|
+
return {
|
|
7358
|
+
stream: wrappedStream,
|
|
7359
|
+
provider: poolStreamProviderName,
|
|
7360
|
+
usage: poolStreamResult.usage,
|
|
7361
|
+
model: poolStreamResult.model || poolStreamModel,
|
|
7362
|
+
finishReason: poolStreamResult.finishReason,
|
|
7363
|
+
toolCalls: poolStreamResult.toolCalls ?? [],
|
|
7364
|
+
toolResults: poolStreamResult.toolResults ?? [],
|
|
7365
|
+
analytics: poolStreamResult.analytics,
|
|
7366
|
+
};
|
|
7367
|
+
}
|
|
7368
|
+
catch (poolStreamError) {
|
|
7369
|
+
if (isAbortError(poolStreamError)) {
|
|
7370
|
+
throw poolStreamError;
|
|
7371
|
+
}
|
|
7372
|
+
if (isNonRetryableProviderError(poolStreamError)) {
|
|
7373
|
+
const poolStreamErrMsg = poolStreamError instanceof Error
|
|
7374
|
+
? poolStreamError.message
|
|
7375
|
+
: String(poolStreamError);
|
|
7376
|
+
streamPool.recordFailure(streamMember, classifyProviderError(poolStreamError));
|
|
7377
|
+
// Wrap in a plain Error so runWithFallbackOrchestration /
|
|
7378
|
+
// streamWithIterationFallback does not re-activate the modelChain
|
|
7379
|
+
// layer on top of an already-exhausted pool.
|
|
7380
|
+
throw new Error(`[ModelPool] non-retryable: ${poolStreamErrMsg}`, {
|
|
7381
|
+
cause: poolStreamError,
|
|
7382
|
+
});
|
|
7383
|
+
}
|
|
7384
|
+
streamPool.recordFailure(streamMember, classifyProviderError(poolStreamError));
|
|
7385
|
+
streamLastError =
|
|
7386
|
+
poolStreamError instanceof Error
|
|
7387
|
+
? poolStreamError
|
|
7388
|
+
: new Error(String(poolStreamError));
|
|
7389
|
+
logger.warn(`[createMCPStream] ModelPool: member ${poolStreamProviderName} failed`, { error: streamLastError.message });
|
|
7390
|
+
}
|
|
7391
|
+
}
|
|
7392
|
+
// All pool stream members failed.
|
|
7393
|
+
// Wrap in a plain Error (not a typed error class) so that the
|
|
7394
|
+
// surrounding fallback orchestrator does not start a second retry layer
|
|
7395
|
+
// over the already-exhausted pool.
|
|
7396
|
+
throw new Error(`[ModelPool] all stream members failed: ${streamLastError?.message ?? "no stream members available"}`);
|
|
7397
|
+
}
|
|
7398
|
+
// ─── End ModelPool stream path ────────────────────────────────────────────
|
|
7117
7399
|
// 🔧 FIX: Pass enhanced system prompt to real streaming
|
|
7118
7400
|
// Tools will be accessed through the streamText call in executeStream
|
|
7119
7401
|
const streamResult = await provider.stream({
|
|
@@ -7513,6 +7795,21 @@ Current user's request: ${currentInput}`;
|
|
|
7513
7795
|
getEventEmitter() {
|
|
7514
7796
|
return this.emitter;
|
|
7515
7797
|
}
|
|
7798
|
+
/**
|
|
7799
|
+
* Returns the instance-level tool-dedup configuration, or `undefined` when
|
|
7800
|
+
* toolDedup was not provided at construction time.
|
|
7801
|
+
*
|
|
7802
|
+
* The stored object is returned as-is whenever `toolDedup` was supplied,
|
|
7803
|
+
* including when `enabled: false` — only the complete absence of a `toolDedup`
|
|
7804
|
+
* option results in `undefined`.
|
|
7805
|
+
*
|
|
7806
|
+
* Called by `BaseProvider.applyToolFiltering` so the dedup pass uses the
|
|
7807
|
+
* same config for every generate/stream call without threading an extra
|
|
7808
|
+
* parameter through the full call stack.
|
|
7809
|
+
*/
|
|
7810
|
+
getToolDedupConfig() {
|
|
7811
|
+
return this.toolDedupConfig;
|
|
7812
|
+
}
|
|
7516
7813
|
/**
|
|
7517
7814
|
* Curator P1-1: synchronous credential health check for a single provider.
|
|
7518
7815
|
*
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModelPool runtime — multi-provider failover with per-member cooldown.
|
|
3
|
+
*
|
|
4
|
+
* This module is PURE (no provider imports, no circular dependencies).
|
|
5
|
+
* It works exclusively with provider NAMES + (provider, model, region) tuples;
|
|
6
|
+
* the existing AIProviderFactory creates actual provider instances.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const pool = new ModelPool({ members: [...], strategy: "round-robin" });
|
|
10
|
+
* const member = pool.selectNext();
|
|
11
|
+
* try {
|
|
12
|
+
* await callProvider(member);
|
|
13
|
+
* pool.recordSuccess(member);
|
|
14
|
+
* } catch (err) {
|
|
15
|
+
* pool.recordFailure(member, classifyProviderError(err));
|
|
16
|
+
* // try next member ...
|
|
17
|
+
* }
|
|
18
|
+
*/
|
|
19
|
+
import type { ModelPoolConfig, ModelPoolMember, ProviderErrorClass } from "../types/index.js";
|
|
20
|
+
/**
|
|
21
|
+
* Classify a provider error into a coarse `ProviderErrorClass`.
|
|
22
|
+
*
|
|
23
|
+
* Rules (checked in order):
|
|
24
|
+
* 1. HTTP 429 or message pattern → "rate_limit"
|
|
25
|
+
* 2. HTTP 401/403, access-denied pattern, or auth keywords → "auth"
|
|
26
|
+
* 3. Context-window / token-limit message → "context_window"
|
|
27
|
+
* 4. HTTP 5xx or server-error message → "server"
|
|
28
|
+
* 5. Network connectivity error → "network"
|
|
29
|
+
* 6. Everything else → "unknown"
|
|
30
|
+
*/
|
|
31
|
+
export declare function classifyProviderError(error: unknown): ProviderErrorClass;
|
|
32
|
+
/**
|
|
33
|
+
* Multi-provider pool with per-member cooldown and strategy-based selection.
|
|
34
|
+
*
|
|
35
|
+
* All state (cooldowns, cursor) is instance-local and resets on construction.
|
|
36
|
+
* Thread safety is not required — Node.js is single-threaded for async work.
|
|
37
|
+
*/
|
|
38
|
+
export declare class ModelPool {
|
|
39
|
+
private readonly config;
|
|
40
|
+
private readonly strategy;
|
|
41
|
+
private readonly cooldownMs;
|
|
42
|
+
private readonly state;
|
|
43
|
+
private cursor;
|
|
44
|
+
private readonly now;
|
|
45
|
+
constructor(config: ModelPoolConfig, injectors?: {
|
|
46
|
+
now?: () => number;
|
|
47
|
+
});
|
|
48
|
+
/**
|
|
49
|
+
* The maximum number of attempts per call (pool config value or member count).
|
|
50
|
+
* Used by callers that drive the retry loop externally.
|
|
51
|
+
*/
|
|
52
|
+
get maxAttempts(): number;
|
|
53
|
+
/**
|
|
54
|
+
* Returns a stable string key for a pool member.
|
|
55
|
+
* Format: `${provider}:${model ?? "*"}:${region ?? "*"}`
|
|
56
|
+
*/
|
|
57
|
+
memberKey(member: ModelPoolMember): string;
|
|
58
|
+
/** Returns members whose cooldown has expired (or were never cooled). */
|
|
59
|
+
availableMembers(): ModelPoolMember[];
|
|
60
|
+
/**
|
|
61
|
+
* Selects the next member to try according to the configured strategy.
|
|
62
|
+
*
|
|
63
|
+
* @param excludedKeys — keys of members already attempted this call.
|
|
64
|
+
* @returns the chosen member, or undefined when all members are exhausted.
|
|
65
|
+
*/
|
|
66
|
+
selectNext(excludedKeys?: Set<string>): ModelPoolMember | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* Records a provider failure, setting a cooldown appropriate for the error class.
|
|
69
|
+
*
|
|
70
|
+
* - Retryable classes (rate_limit, server, network, unknown): timed cooldown
|
|
71
|
+
* for `cooldownMs` so the member can recover and be retried later.
|
|
72
|
+
* - Non-retryable classes (auth, context_window): permanent cooldown for the
|
|
73
|
+
* lifetime of this ModelPool instance, because these errors are structural
|
|
74
|
+
* and will not resolve between calls (wrong credentials, model not available
|
|
75
|
+
* in team whitelist, payload exceeds the model's context window).
|
|
76
|
+
*/
|
|
77
|
+
recordFailure(member: ModelPoolMember, errorClass: ProviderErrorClass): void;
|
|
78
|
+
/**
|
|
79
|
+
* Records a successful response, clearing any existing cooldown for this member
|
|
80
|
+
* so it remains fully available.
|
|
81
|
+
*/
|
|
82
|
+
recordSuccess(member: ModelPoolMember): void;
|
|
83
|
+
}
|