@juspay/neurolink 9.76.0 → 9.78.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +341 -341
  3. package/dist/core/toolRouting.d.ts +9 -0
  4. package/dist/core/toolRouting.js +178 -1
  5. package/dist/core/toolRoutingEmbedding.d.ts +112 -0
  6. package/dist/core/toolRoutingEmbedding.js +322 -0
  7. package/dist/index.d.ts +31 -0
  8. package/dist/index.js +35 -0
  9. package/dist/lib/core/toolRouting.d.ts +9 -0
  10. package/dist/lib/core/toolRouting.js +178 -1
  11. package/dist/lib/core/toolRoutingEmbedding.d.ts +112 -0
  12. package/dist/lib/core/toolRoutingEmbedding.js +323 -0
  13. package/dist/lib/index.d.ts +31 -0
  14. package/dist/lib/index.js +35 -0
  15. package/dist/lib/neurolink.d.ts +22 -0
  16. package/dist/lib/neurolink.js +544 -175
  17. package/dist/lib/routing/index.d.ts +7 -0
  18. package/dist/lib/routing/index.js +8 -0
  19. package/dist/lib/routing/modelPool.d.ts +83 -0
  20. package/dist/lib/routing/modelPool.js +243 -0
  21. package/dist/lib/routing/requestRouter.d.ts +30 -0
  22. package/dist/lib/routing/requestRouter.js +81 -0
  23. package/dist/lib/types/config.d.ts +18 -0
  24. package/dist/lib/types/index.d.ts +2 -0
  25. package/dist/lib/types/index.js +4 -0
  26. package/dist/lib/types/modelPool.d.ts +47 -0
  27. package/dist/lib/types/modelPool.js +11 -0
  28. package/dist/lib/types/requestRouter.d.ts +75 -0
  29. package/dist/lib/types/requestRouter.js +16 -0
  30. package/dist/lib/types/toolRouting.d.ts +171 -0
  31. package/dist/lib/utils/providerErrorClassification.d.ts +24 -0
  32. package/dist/lib/utils/providerErrorClassification.js +89 -0
  33. package/dist/neurolink.d.ts +22 -0
  34. package/dist/neurolink.js +544 -175
  35. package/dist/routing/index.d.ts +7 -0
  36. package/dist/routing/index.js +7 -0
  37. package/dist/routing/modelPool.d.ts +83 -0
  38. package/dist/routing/modelPool.js +242 -0
  39. package/dist/routing/requestRouter.d.ts +30 -0
  40. package/dist/routing/requestRouter.js +80 -0
  41. package/dist/types/config.d.ts +18 -0
  42. package/dist/types/index.d.ts +2 -0
  43. package/dist/types/index.js +4 -0
  44. package/dist/types/modelPool.d.ts +47 -0
  45. package/dist/types/modelPool.js +10 -0
  46. package/dist/types/requestRouter.d.ts +75 -0
  47. package/dist/types/requestRouter.js +15 -0
  48. package/dist/types/toolRouting.d.ts +171 -0
  49. package/dist/utils/providerErrorClassification.d.ts +24 -0
  50. package/dist/utils/providerErrorClassification.js +88 -0
  51. package/package.json +6 -2
@@ -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, AuthenticationError, AuthorizationError, InvalidModelError, ModelAccessDeniedError, } from "./types/index.js";
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
- * Curator P2-3: detect model-access-denied without requiring the typed
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,11 @@ 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
+ // Persisted vector cache for the L2 embedding fast-path (ITEM B). Populated
367
+ // on the first turn and reused across subsequent turns so tool embedding
368
+ // vectors are computed once. Cleared when the catalog changes via
369
+ // setToolRoutingServers() so stale vectors are never reused.
370
+ toolRoutingVectorCache;
448
371
  // Opt-in tool-signature deduplication config.
449
372
  toolDedupConfig;
450
373
  // Add orchestration property
@@ -458,6 +381,12 @@ export class NeuroLink {
458
381
  // Curator P2-3: instance-level fallback policy. Read by
459
382
  // runWithFallbackOrchestration on model-access-denied.
460
383
  fallbackConfig = {};
384
+ // ModelPool: opt-in multi-provider failover with per-member cooldown.
385
+ // Built once from config.modelPool in the constructor; null when not configured.
386
+ modelPool;
387
+ // RequestRouter: pluggable pre-call provider/model selector.
388
+ // Stored directly from config.requestRouter; null when not configured.
389
+ requestRouter;
461
390
  /**
462
391
  * Merge instance-level credentials with per-call credentials.
463
392
  *
@@ -862,6 +791,10 @@ export class NeuroLink {
862
791
  if (config?.toolDedup) {
863
792
  this.toolDedupConfig = { ...config.toolDedup };
864
793
  }
794
+ // ModelPool: build one instance from config; null when not configured.
795
+ this.modelPool = config?.modelPool ? new ModelPool(config.modelPool) : null;
796
+ // RequestRouter: store the host-supplied function; null when not configured.
797
+ this.requestRouter = config?.requestRouter ?? null;
865
798
  logger.setEventEmitter(this.emitter);
866
799
  // Read tool cache duration from environment variables, with a default
867
800
  const cacheDurationEnv = process.env.NEUROLINK_TOOL_CACHE_DURATION;
@@ -3172,6 +3105,11 @@ Current user's request: ${currentInput}`;
3172
3105
  async runStandardGenerateRequest(options, originalPrompt, generateSpan) {
3173
3106
  const startTime = Date.now();
3174
3107
  await this.maybeApplyGenerateOrchestration(options);
3108
+ // Pre-call request router: opt-in, only when the caller did not set both
3109
+ // provider+model. Fails open (any router error leaves options unchanged).
3110
+ await this.applyRequestRouter(options, options.input?.text ?? originalPrompt ?? "", !options.disableTools &&
3111
+ (!!(options.tools && Object.keys(options.tools).length > 0) ||
3112
+ this.getCustomTools().size > 0), !!(options.input?.images && options.input.images.length > 0), options.thinkingConfig?.thinkingLevel);
3175
3113
  this.emitter.emit("generation:start", {
3176
3114
  provider: options.provider || "auto",
3177
3115
  timestamp: startTime,
@@ -3280,6 +3218,94 @@ Current user's request: ${currentInput}`;
3280
3218
  });
3281
3219
  }
3282
3220
  }
3221
+ /**
3222
+ * Applies the host-configured `requestRouter` to `options` in place.
3223
+ *
3224
+ * The router is skipped when:
3225
+ * - no `requestRouter` is configured on this instance, or
3226
+ * - the caller explicitly set both `options.provider` AND `options.model`
3227
+ * (we only skip the router if BOTH are set; a caller setting only one
3228
+ * still lets the router fill in the other field).
3229
+ *
3230
+ * Fails open: any router error is logged at WARN level and the call
3231
+ * continues with the original options unmodified.
3232
+ *
3233
+ * @param options — the mutable options object (generate or stream).
3234
+ * @param promptText — the text prompt used to build the RouterInputContext.
3235
+ * @param hasTools — true when at least one tool is available for this call.
3236
+ * @param requiresVision — true when the call includes image attachments.
3237
+ * @param thinkingLevel — optional thinking level string from the call options.
3238
+ */
3239
+ async applyRequestRouter(options, promptText, hasTools, requiresVision, thinkingLevel) {
3240
+ if (!this.requestRouter) {
3241
+ return;
3242
+ }
3243
+ // Skip if the caller explicitly set both provider AND model — they know
3244
+ // what they want; the router should not override an intentional choice.
3245
+ if (options.provider && options.model) {
3246
+ return;
3247
+ }
3248
+ // Skip when a ModelPool is also configured: the pool unconditionally
3249
+ // overrides provider+model for each member it selects. Allowing the
3250
+ // router to run first would set options.provider/model to a value the
3251
+ // pool immediately clobbers, creating silent ownership confusion and
3252
+ // making the pool bypass the "user didn't set provider+model" guard above
3253
+ // on subsequent attempts.
3254
+ if (this.modelPool) {
3255
+ logger.debug("[NeuroLink] applyRequestRouter: skipped — modelPool takes precedence");
3256
+ return;
3257
+ }
3258
+ const ctx = {
3259
+ prompt: promptText,
3260
+ // Cheap token estimate: ~4 chars/token.
3261
+ estimatedInputTokens: Math.ceil(promptText.length / 4),
3262
+ hasTools,
3263
+ requiresVision,
3264
+ thinkingLevel,
3265
+ };
3266
+ try {
3267
+ const decision = await this.requestRouter(ctx);
3268
+ // Apply provider/model/region as a coherent set: when the router supplies
3269
+ // a provider (the caller didn't pin one), take its model and region too.
3270
+ // If the caller pinned the provider but not the model, only apply the
3271
+ // router's model when it targets the same provider.
3272
+ if (decision.provider && !options.provider) {
3273
+ options.provider = decision.provider;
3274
+ // Adopt the router's model and region only when they belong to the
3275
+ // provider the router just selected — never mix a router model onto a
3276
+ // caller-pinned different provider.
3277
+ if (decision.model && !options.model) {
3278
+ options.model = decision.model;
3279
+ }
3280
+ if (decision.region && !options.region) {
3281
+ options.region = decision.region;
3282
+ }
3283
+ }
3284
+ else if (!decision.provider) {
3285
+ // Router returned a no-op or model-only decision.
3286
+ if (decision.model && !options.model) {
3287
+ options.model = decision.model;
3288
+ }
3289
+ if (decision.region && !options.region) {
3290
+ options.region = decision.region;
3291
+ }
3292
+ }
3293
+ // If decision.provider differs from the caller-pinned options.provider,
3294
+ // we skip the router result entirely to avoid an incoherent pairing.
3295
+ if (decision.reason) {
3296
+ logger.debug("[NeuroLink] requestRouter decision", {
3297
+ reason: decision.reason,
3298
+ provider: options.provider,
3299
+ model: options.model,
3300
+ });
3301
+ }
3302
+ }
3303
+ catch (routerErr) {
3304
+ logger.warn("[NeuroLink] requestRouter threw — proceeding unrouted", {
3305
+ error: routerErr instanceof Error ? routerErr.message : String(routerErr),
3306
+ });
3307
+ }
3308
+ }
3283
3309
  async prepareGenerateAugmentations(options) {
3284
3310
  if (options.rag?.files?.length) {
3285
3311
  try {
@@ -4961,6 +4987,149 @@ Current user's request: ${currentInput}`;
4961
4987
  tryProviders,
4962
4988
  allowFallback: !requestedProvider || !!preferredOrchestrated,
4963
4989
  });
4990
+ // ─── ModelPool path ──────────────────────────────────────────────────────
4991
+ // When a ModelPool is configured, source the candidate sequence from the
4992
+ // pool instead of (or in addition to) the static tryProviders list.
4993
+ // Preserves the existing isNonRetryableProviderError short-circuit and
4994
+ // AbortError propagation semantics. Falls through to the standard path
4995
+ // when no pool is configured.
4996
+ if (this.modelPool) {
4997
+ const pool = this.modelPool;
4998
+ const maxPoolAttempts = pool.maxAttempts;
4999
+ const triedKeys = new Set();
5000
+ let poolLastError = null;
5001
+ for (let attempt = 0; attempt < maxPoolAttempts; attempt++) {
5002
+ if (options.abortSignal?.aborted) {
5003
+ throw new DOMException("The operation was aborted", "AbortError");
5004
+ }
5005
+ const member = pool.selectNext(triedKeys);
5006
+ if (!member) {
5007
+ // All available members exhausted.
5008
+ break;
5009
+ }
5010
+ triedKeys.add(pool.memberKey(member));
5011
+ // Temporarily override provider/model/region from the pool member.
5012
+ const savedProvider = options.provider;
5013
+ const savedModel = options.model;
5014
+ const savedRegion = options.region;
5015
+ options.provider = member.provider;
5016
+ // When the member omits model/region, clear inherited values so the
5017
+ // provider's own default is used rather than a mismatched caller value.
5018
+ options.model = member.model ?? undefined;
5019
+ options.region = member.region ?? undefined;
5020
+ const poolProviderName = member.provider;
5021
+ logger.debug(`[${functionTag}] ModelPool: attempting member`, {
5022
+ provider: poolProviderName,
5023
+ model: member.model ?? options.model,
5024
+ attempt,
5025
+ });
5026
+ try {
5027
+ // Get conversation messages for context (use pre-compacted if provided)
5028
+ const poolOptionsWithMessages = options;
5029
+ const poolConversationMessages = poolOptionsWithMessages
5030
+ .conversationMessages?.length
5031
+ ? poolOptionsWithMessages.conversationMessages
5032
+ : await getConversationMessages(this.conversationMemory, options);
5033
+ const poolProvider = await AIProviderFactory.createProvider(poolProviderName, options.model, !options.disableTools, this, options.region, this.resolveCredentials(options.credentials));
5034
+ poolProvider.setTraceContext(this._metricsTraceContext);
5035
+ poolProvider.setupToolExecutor({
5036
+ customTools: this.getCustomTools(),
5037
+ executeTool: (toolName, params) => this.executeTool(toolName, params, {
5038
+ disableToolCache: options.disableToolCache,
5039
+ }),
5040
+ }, functionTag);
5041
+ const poolResult = await poolProvider.generate({
5042
+ ...options,
5043
+ conversationMessages: poolConversationMessages,
5044
+ });
5045
+ if (!poolResult) {
5046
+ throw new Error(`ModelPool: provider ${poolProviderName} returned null result`);
5047
+ }
5048
+ pool.recordSuccess(member);
5049
+ const responseTime = Date.now() - startTime;
5050
+ // Restore original options fields so callers see a clean object.
5051
+ options.provider = savedProvider;
5052
+ options.model = savedModel;
5053
+ options.region = savedRegion;
5054
+ return {
5055
+ content: poolResult.content || "",
5056
+ provider: poolProviderName,
5057
+ model: poolResult.model,
5058
+ usage: poolResult.usage,
5059
+ responseTime,
5060
+ finishReason: poolResult.finishReason,
5061
+ toolsUsed: poolResult.toolsUsed || [],
5062
+ toolExecutions: poolResult.toolExecutions?.map((te) => {
5063
+ const t = te;
5064
+ return {
5065
+ ...te,
5066
+ toolName: te.name,
5067
+ executionTime: typeof t.executionTime === "number"
5068
+ ? t.executionTime
5069
+ : typeof t.duration === "number"
5070
+ ? t.duration
5071
+ : 0,
5072
+ success: typeof t.success === "boolean"
5073
+ ? t.success
5074
+ : t.status === "success",
5075
+ };
5076
+ }),
5077
+ enhancedWithTools: !!poolResult.toolExecutions?.length,
5078
+ analytics: poolResult.analytics,
5079
+ evaluation: poolResult.evaluation,
5080
+ audio: poolResult.audio,
5081
+ video: poolResult.video,
5082
+ avatar: poolResult.avatar,
5083
+ music: poolResult.music,
5084
+ ppt: poolResult.ppt,
5085
+ imageOutput: poolResult.imageOutput,
5086
+ reasoning: poolResult.reasoning,
5087
+ reasoningTokens: poolResult.reasoningTokens,
5088
+ _generationEndEmitted: poolResult._generationEndEmitted,
5089
+ };
5090
+ }
5091
+ catch (poolError) {
5092
+ // Restore options unconditionally on failure.
5093
+ options.provider = savedProvider;
5094
+ options.model = savedModel;
5095
+ options.region = savedRegion;
5096
+ if (isAbortError(poolError)) {
5097
+ throw poolError;
5098
+ }
5099
+ if (isNonRetryableProviderError(poolError)) {
5100
+ const poolErrMsg = poolError instanceof Error
5101
+ ? poolError.message
5102
+ : String(poolError);
5103
+ logger.warn(`[${functionTag}] ModelPool: non-retryable error from ${poolProviderName}, stopping pool`, { error: poolErrMsg });
5104
+ pool.recordFailure(member, classifyProviderError(poolError));
5105
+ // Wrap in a plain Error so runWithFallbackOrchestration does not
5106
+ // mistake this for a ModelAccessDeniedError and start a second
5107
+ // retry layer on top of the already-exhausted pool.
5108
+ throw new Error(`[ModelPool] non-retryable: ${poolErrMsg}`, {
5109
+ cause: poolError,
5110
+ });
5111
+ }
5112
+ pool.recordFailure(member, classifyProviderError(poolError));
5113
+ poolLastError =
5114
+ poolError instanceof Error
5115
+ ? poolError
5116
+ : new Error(String(poolError));
5117
+ logger.warn(`[${functionTag}] ModelPool: member ${poolProviderName} failed`, { error: poolLastError.message });
5118
+ }
5119
+ }
5120
+ // All pool members failed or were exhausted.
5121
+ const poolResponseTime = Date.now() - startTime;
5122
+ logger.error(`[${functionTag}] ModelPool: all members failed`, {
5123
+ triedKeys: Array.from(triedKeys),
5124
+ lastError: poolLastError?.message,
5125
+ responseTime: poolResponseTime,
5126
+ });
5127
+ // Wrap in a plain Error (not a typed error class) so that
5128
+ // runWithFallbackOrchestration does not re-activate the modelChain /
5129
+ // providerFallback layer on top of an already-exhausted pool.
5130
+ throw new Error(`[ModelPool] all members failed: ${poolLastError?.message ?? "no members available"}`);
5131
+ }
5132
+ // ─── End ModelPool path ───────────────────────────────────────────────────
4964
5133
  let lastError = null;
4965
5134
  // Try each provider in order
4966
5135
  for (const providerName of tryProviders) {
@@ -5659,6 +5828,11 @@ Current user's request: ${currentInput}`;
5659
5828
  // classify correctly. After the workflow short-circuit, so workflow
5660
5829
  // streams skip it.
5661
5830
  await context.with(streamSpanContext, () => this.setLangfuseContextFromOptions(options, () => this.applyToolRoutingExclusions(options, originalPrompt)));
5831
+ // Pre-call request router for stream: opt-in, fails open.
5832
+ await this.applyRequestRouter(options, originalPrompt, !options.disableTools &&
5833
+ (!!(options.tools && Object.keys(options.tools).length > 0) ||
5834
+ this.getCustomTools().size > 0), !!(options.input?.images && options.input.images.length > 0), options
5835
+ .thinking?.thinkingLevel);
5662
5836
  // TTS Mode 2 deferred: stream() emits text first, then synthesizes the
5663
5837
  // accumulated response into a single audio chunk at end-of-stream and
5664
5838
  // resolves `streamResult.audio` with the same TTSResult. The resolver is
@@ -5765,7 +5939,11 @@ Current user's request: ${currentInput}`;
5765
5939
  // Require a non-empty sessionId to avoid anonymous sessions sharing a
5766
5940
  // ":query" namespace (cross-session cache leak).
5767
5941
  const cacheKey = cacheEnabled && sessionId ? `${sessionId}:${routingQuery}` : undefined;
5942
+ const routableServerCount = catalog.filter((s) => !(routingConfig.alwaysIncludeServerIds ?? []).includes(s.id)).length;
5768
5943
  // --- ITEM E: build the emitDecision callback ---
5944
+ // Constructed BEFORE the cache-hit check so it is available for all
5945
+ // outcome paths, including cache-hit turns (Finding 4).
5946
+ const routingStartTime = Date.now();
5769
5947
  const emitDecision = (decision) => {
5770
5948
  try {
5771
5949
  const activeSpan = trace.getActiveSpan();
@@ -5780,100 +5958,176 @@ Current user's request: ${currentInput}`;
5780
5958
  activeSpan.setAttribute("tool_routing.excluded_tool_count", decision.excludedToolCount);
5781
5959
  activeSpan.setAttribute("tool_routing.cache_hit", decision.cacheHit);
5782
5960
  activeSpan.setAttribute("tool_routing.duration_ms", decision.durationMs);
5961
+ // Optional L2 / tool-granularity fields (present only when embedding ran).
5962
+ if (decision.embeddingActivated !== undefined) {
5963
+ activeSpan.setAttribute("tool_routing.embedding_activated", decision.embeddingActivated);
5964
+ }
5965
+ if (decision.candidateToolCount !== undefined) {
5966
+ activeSpan.setAttribute("tool_routing.candidate_tool_count", decision.candidateToolCount);
5967
+ }
5968
+ if (decision.granularity !== undefined) {
5969
+ activeSpan.setAttribute("tool_routing.granularity", decision.granularity);
5970
+ }
5783
5971
  }
5784
5972
  catch {
5785
5973
  // Telemetry must never affect routing behaviour.
5786
5974
  }
5787
5975
  };
5788
- // Unified hit/miss block: always produces raw (pre-stickiness) exclusions
5789
- // so stickiness can be applied identically on both paths below.
5976
+ // Check the cache first.
5977
+ if (cacheEnabled && cache && cacheKey !== undefined) {
5978
+ const cached = cache.get(cacheKey);
5979
+ if (cached) {
5980
+ // Decrement stickiness turn counter even on a cache hit so the window
5981
+ // advances correctly regardless of whether the LLM router ran
5982
+ // (Finding 3). Re-apply stickiness to the cached exclusion list so
5983
+ // the live stickiness window, not the one at write-time, is honoured
5984
+ // (Finding 2 complement: we stored the pre-stickiness list, so
5985
+ // re-applying here gives the correct per-turn view).
5986
+ let cachedExcluded = cached.excludedToolNames;
5987
+ if (stickinessEnabled && sessionId) {
5988
+ try {
5989
+ const stickyIds = cache.getStickyServerIds(sessionId);
5990
+ if (stickyIds.length > 0) {
5991
+ cachedExcluded = cachedExcluded.filter((toolName) => !stickyIds.some((id) => toolName.startsWith(`${id}_`)));
5992
+ }
5993
+ }
5994
+ catch {
5995
+ // Stickiness failure is non-fatal.
5996
+ }
5997
+ }
5998
+ // Notify the emitDecision callback so custom telemetry sinks observe
5999
+ // every routing outcome, not just non-cached turns (Finding 4).
6000
+ const cachedSelectedSet = new Set(cached.selectedServerIds);
6001
+ const cachedExcludedServerIds = catalog
6002
+ .map((e) => e.id)
6003
+ .filter((id) => !cachedSelectedSet.has(id));
6004
+ emitDecision({
6005
+ outcome: "cache-hit",
6006
+ selectedServerIds: cached.selectedServerIds,
6007
+ excludedServerIds: cachedExcludedServerIds,
6008
+ hallucinatedIds: [],
6009
+ excludedToolCount: cachedExcluded.length,
6010
+ routableServerCount,
6011
+ cacheHit: true,
6012
+ durationMs: Date.now() - routingStartTime,
6013
+ });
6014
+ logger.debug("[ToolRouting] Cache hit, skipping router LLM", {
6015
+ hasSessionId: !!sessionId,
6016
+ routingQueryLength: routingQuery.length,
6017
+ });
6018
+ if (cachedExcluded.length > 0) {
6019
+ options.excludeTools = [
6020
+ ...(options.excludeTools ?? []),
6021
+ ...cachedExcluded,
6022
+ ];
6023
+ }
6024
+ return;
6025
+ }
6026
+ }
6027
+ // The router call below re-enters the public generate(), whose finally
6028
+ // block resets _disableToolCacheForCurrentRequest to false. That flag is
6029
+ // turn-scoped (set at the top of this turn) and read by the main tool
6030
+ // execution path that runs after routing, so save it before the router
6031
+ // call and restore it afterward to keep the turn's cache setting intact.
6032
+ const cacheDisabledForCurrentRequest = this._disableToolCacheForCurrentRequest;
5790
6033
  let routedExcludeTools;
5791
- let selectedServerIds = [];
5792
6034
  let resolvedDecision;
5793
- let fromCache = false;
5794
- const cached = cacheEnabled && cache && cacheKey !== undefined
5795
- ? cache.get(cacheKey)
5796
- : undefined;
5797
- if (cached) {
5798
- // Cache HIT — use stored raw exclusions (pre-stickiness).
5799
- fromCache = true;
5800
- routedExcludeTools = [...cached.excludedToolNames];
5801
- selectedServerIds = [...cached.selectedServerIds];
5802
- logger.debug("[ToolRouting] Cache hit, skipping router LLM", {
5803
- cacheKey,
5804
- });
5805
- // Emit cache-hit telemetry with full parity to the miss path.
5806
- try {
5807
- const activeSpan = trace.getActiveSpan();
5808
- if (activeSpan) {
5809
- const routableCount = catalog.filter((s) => !(routingConfig.alwaysIncludeServerIds ?? []).includes(s.id)).length;
5810
- activeSpan.setAttribute("tool_routing.outcome", "cache-hit");
5811
- activeSpan.setAttribute("tool_routing.routable_server_count", routableCount);
5812
- activeSpan.setAttribute("tool_routing.cache_hit", true);
5813
- activeSpan.setAttribute("tool_routing.selected_server_ids", spanJsonAttribute(selectedServerIds));
5814
- activeSpan.setAttribute("tool_routing.excluded_tool_count", routedExcludeTools.length);
6035
+ try {
6036
+ // Intercept the decision so we can store it in the cache.
6037
+ const captureDecision = (decision) => {
6038
+ resolvedDecision = decision;
6039
+ emitDecision(decision);
6040
+ };
6041
+ // --- ITEM B: build the embedFn for the L2 embedding fast-path ---
6042
+ // The vector cache is persisted at the NeuroLink instance level so
6043
+ // tool embedding vectors are computed once and reused across turns
6044
+ // (Finding 1 fix). It is cleared by setToolRoutingServers() when the
6045
+ // catalog changes so stale vectors are never used after an update.
6046
+ let routingEmbedFn;
6047
+ const embeddingCfg = routingConfig.embedding;
6048
+ if (embeddingCfg?.enabled === true) {
6049
+ try {
6050
+ // Resolve the embedding provider: use the explicitly configured one
6051
+ // if present, otherwise fall back to the stream/generate call's
6052
+ // provider. The factory call is wrapped in try/catch so a provider
6053
+ // that doesn't support embedMany (it throws at call time, not
6054
+ // construction time) fails open when routingEmbedFn is invoked.
6055
+ const embProviderName = embeddingCfg.provider ??
6056
+ (options.provider && options.provider !== "auto"
6057
+ ? options.provider
6058
+ : undefined) ??
6059
+ routingConfig.routerModel?.provider;
6060
+ if (embProviderName) {
6061
+ const embProvider = await AIProviderFactory.createProvider(embProviderName, embeddingCfg.model, true, this, undefined, this.resolveCredentials(options.credentials));
6062
+ // Bind embedMany with the configured model (may be undefined —
6063
+ // the provider uses its default embedding model in that case).
6064
+ routingEmbedFn = (texts) => withTimeout(embProvider.embedMany(texts, embeddingCfg.model), embeddingCfg.timeoutMs ?? 10000);
6065
+ // Lazy-init the persistent vector cache for this instance.
6066
+ // Subsequent turns reuse the same Map so text→vector lookups
6067
+ // already populated from earlier turns are served from memory.
6068
+ if (!this.toolRoutingVectorCache) {
6069
+ this.toolRoutingVectorCache = new Map();
6070
+ }
6071
+ }
6072
+ }
6073
+ catch (embSetupError) {
6074
+ logger.debug("[ToolRouting] Embedding provider setup failed, L2 path disabled for this turn", {
6075
+ error: embSetupError instanceof Error
6076
+ ? embSetupError.message
6077
+ : String(embSetupError),
6078
+ });
6079
+ // routingEmbedFn remains undefined — fast-path is skipped.
5815
6080
  }
5816
6081
  }
5817
- catch {
5818
- // Telemetry must never affect routing behaviour.
5819
- }
6082
+ routedExcludeTools = await resolveToolRoutingExclusions({
6083
+ catalog,
6084
+ alwaysIncludeServerIds: routingConfig.alwaysIncludeServerIds ?? [],
6085
+ userQuery: routingQuery,
6086
+ routerPromptPrefix: routingConfig.routerPromptPrefix,
6087
+ routerModel: {
6088
+ provider: routingConfig.routerModel?.provider ??
6089
+ options.provider,
6090
+ model: routingConfig.routerModel?.model ?? options.model,
6091
+ region: routingConfig.routerModel?.region ?? options.region,
6092
+ temperature: routingConfig.routerModel?.temperature,
6093
+ },
6094
+ timeoutMs: routingConfig.timeoutMs ?? DEFAULT_TOOL_ROUTING_TIMEOUT_MS,
6095
+ // Forward the abort signal so a cancelled turn aborts the router
6096
+ // call promptly instead of waiting out the routing timeout.
6097
+ generateFn: (generateOptions) => this.generate({
6098
+ ...generateOptions,
6099
+ abortSignal: options.abortSignal,
6100
+ }),
6101
+ emitDecision: captureDecision,
6102
+ // L2 / ITEM D — only populated when embedding is configured.
6103
+ embedFn: routingEmbedFn,
6104
+ embeddingConfig: embeddingCfg,
6105
+ granularity: routingConfig.granularity ?? "server",
6106
+ // Pass the persistent vector cache so tool embeddings are reused
6107
+ // across turns (Finding 1).
6108
+ embeddingVectorCache: routingEmbedFn !== undefined
6109
+ ? this.toolRoutingVectorCache
6110
+ : undefined,
6111
+ });
5820
6112
  }
5821
- else {
5822
- // Cache MISS — run the router LLM.
5823
- // The router call re-enters the public generate(), whose finally block
5824
- // resets _disableToolCacheForCurrentRequest to false. That flag is
5825
- // turn-scoped and read by the main tool execution path after routing, so
5826
- // save it before the router call and restore it afterward.
5827
- const cacheDisabledForCurrentRequest = this._disableToolCacheForCurrentRequest;
5828
- try {
5829
- // Intercept the decision so we can store it in the cache.
5830
- const captureDecision = (decision) => {
5831
- resolvedDecision = decision;
5832
- emitDecision(decision);
5833
- };
5834
- routedExcludeTools = await resolveToolRoutingExclusions({
5835
- catalog,
5836
- alwaysIncludeServerIds: routingConfig.alwaysIncludeServerIds ?? [],
5837
- userQuery: routingQuery,
5838
- routerPromptPrefix: routingConfig.routerPromptPrefix,
5839
- routerModel: {
5840
- provider: routingConfig.routerModel?.provider ??
5841
- options.provider,
5842
- model: routingConfig.routerModel?.model ?? options.model,
5843
- region: routingConfig.routerModel?.region ?? options.region,
5844
- temperature: routingConfig.routerModel?.temperature,
5845
- },
5846
- timeoutMs: routingConfig.timeoutMs ?? DEFAULT_TOOL_ROUTING_TIMEOUT_MS,
5847
- // Forward the abort signal so a cancelled turn aborts the router
5848
- // call promptly instead of waiting out the routing timeout.
5849
- generateFn: (generateOptions) => this.generate({
5850
- ...generateOptions,
5851
- abortSignal: options.abortSignal,
5852
- }),
5853
- emitDecision: captureDecision,
5854
- });
5855
- }
5856
- finally {
5857
- this._disableToolCacheForCurrentRequest =
5858
- cacheDisabledForCurrentRequest;
5859
- }
5860
- if (resolvedDecision?.outcome === "applied") {
5861
- selectedServerIds = resolvedDecision.selectedServerIds;
5862
- }
6113
+ finally {
6114
+ this._disableToolCacheForCurrentRequest =
6115
+ cacheDisabledForCurrentRequest;
5863
6116
  }
5864
6117
  // Aborted during the router call — skip applying now-stale exclusions;
5865
6118
  // the main generation path enforces the abort itself.
5866
6119
  if (options.abortSignal?.aborted) {
5867
6120
  return;
5868
6121
  }
5869
- // Capture raw (pre-stickiness) exclusions for cache storage on MISS path.
5870
- const rawExclusions = [...routedExcludeTools];
5871
- // Apply stickiness on BOTH hit and miss paths: the sticky ids were
5872
- // recorded on a prior turn and represent servers that should stay warm for
5873
- // the current turn. Consuming (decrementing) them before recordSelection
5874
- // ensures the window covers the correct set of future turns rather than
5875
- // burning one count on the same turn the selection is recorded
5876
- // (off-by-one fix).
6122
+ // Snapshot the raw (pre-stickiness) exclusion list before applying
6123
+ // stickiness overrides. The cache stores this snapshot so future cache
6124
+ // hits can re-apply the then-current stickiness state (Finding 2).
6125
+ const preStickinessExcludeTools = routedExcludeTools;
6126
+ // Apply stickiness FIRST: the sticky ids were recorded on a prior turn and
6127
+ // represent servers that should stay warm for the current turn. Consuming
6128
+ // (decrementing) them before recordSelection ensures the window covers the
6129
+ // correct set of future turns rather than burning one count on the same
6130
+ // turn the selection is recorded (off-by-one fix).
5877
6131
  if (stickinessEnabled && cache && sessionId) {
5878
6132
  try {
5879
6133
  const stickyIds = cache.getStickyServerIds(sessionId);
@@ -5888,15 +6142,16 @@ Current user's request: ${currentInput}`;
5888
6142
  // Stickiness failure is non-fatal.
5889
6143
  }
5890
6144
  }
5891
- // --- ITEM C: store result for future turns (MISS path only) ---
5892
- // Cache stores RAW (pre-stickiness) exclusions so the cached value is
5893
- // correct regardless of which sessions hit it on subsequent turns.
5894
- if (!fromCache && resolvedDecision?.outcome === "applied" && cache) {
6145
+ // --- ITEM C: store result for future turns ---
6146
+ if (resolvedDecision?.outcome === "applied" && cache) {
6147
+ // Store the PRE-stickiness exclusion list in the cache so future hits
6148
+ // re-apply the live stickiness window rather than the one from this
6149
+ // turn (Finding 2).
5895
6150
  if (cacheEnabled && cacheKey !== undefined) {
5896
6151
  try {
5897
6152
  cache.set(cacheKey, {
5898
- excludedToolNames: rawExclusions,
5899
- selectedServerIds,
6153
+ excludedToolNames: preStickinessExcludeTools,
6154
+ selectedServerIds: resolvedDecision.selectedServerIds,
5900
6155
  });
5901
6156
  }
5902
6157
  catch {
@@ -5908,7 +6163,7 @@ Current user's request: ${currentInput}`;
5908
6163
  // the same turn it is set (the off-by-one fix above).
5909
6164
  if (stickinessEnabled && sessionId) {
5910
6165
  try {
5911
- cache.recordSelection(sessionId, selectedServerIds);
6166
+ cache.recordSelection(sessionId, resolvedDecision.selectedServerIds);
5912
6167
  }
5913
6168
  catch {
5914
6169
  // Stickiness failure is non-fatal.
@@ -6005,6 +6260,12 @@ Current user's request: ${currentInput}`;
6005
6260
  return;
6006
6261
  }
6007
6262
  this.toolRoutingConfig.servers = servers;
6263
+ // Clear the persisted vector cache so tool vectors are recomputed against
6264
+ // the new catalog on the next turn (stale vectors must never be reused).
6265
+ this.toolRoutingVectorCache = undefined;
6266
+ // Cached routing decisions are catalog-dependent too; force the next turn
6267
+ // to recompute exclusions against the new server/tool set.
6268
+ this.toolRoutingCacheInstance = undefined;
6008
6269
  }
6009
6270
  async validateStreamRequestOptions(options, startTime) {
6010
6271
  await this.validateStreamInput(options);
@@ -7119,6 +7380,114 @@ Current user's request: ${currentInput}`;
7119
7380
  }
7120
7381
  }
7121
7382
  }
7383
+ // ─── ModelPool stream path ────────────────────────────────────────────────
7384
+ // When a pool is configured, replaces the single provider.stream() call
7385
+ // with a retry loop over pool members. Compaction/budget-check above ran
7386
+ // once on the initially-selected `providerName`; the pool may redirect to
7387
+ // a different provider, but those checks are conservative enough that the
7388
+ // redirected provider will also accept the payload (if it has a smaller
7389
+ // window, the budget check would have already thrown before we get here).
7390
+ // On exhaustion, throws the last member error — does NOT fall through to
7391
+ // the static provider path below.
7392
+ if (this.modelPool) {
7393
+ const streamPool = this.modelPool;
7394
+ const maxStreamAttempts = streamPool.maxAttempts;
7395
+ const streamTriedKeys = new Set();
7396
+ let streamLastError = null;
7397
+ for (let attempt = 0; attempt < maxStreamAttempts; attempt++) {
7398
+ if (options.abortSignal?.aborted) {
7399
+ throw new DOMException("The operation was aborted", "AbortError");
7400
+ }
7401
+ const streamMember = streamPool.selectNext(streamTriedKeys);
7402
+ if (!streamMember) {
7403
+ break;
7404
+ }
7405
+ streamTriedKeys.add(streamPool.memberKey(streamMember));
7406
+ // Override provider/model/region from the pool member.
7407
+ // When the member omits model/region, use undefined (provider default)
7408
+ // rather than inheriting the caller's value for a different provider.
7409
+ const poolStreamProviderName = streamMember.provider;
7410
+ const poolStreamModel = streamMember.model ?? undefined;
7411
+ const poolStreamRegion = streamMember.region ?? undefined;
7412
+ logger.debug(`[createMCPStream] ModelPool: attempting member ${poolStreamProviderName}`, { model: poolStreamModel, attempt });
7413
+ try {
7414
+ const poolStreamProvider = await AIProviderFactory.createProvider(poolStreamProviderName, poolStreamModel, !options.disableTools, this, poolStreamRegion, this.resolveCredentials(options.credentials));
7415
+ poolStreamProvider.setTraceContext(this._metricsTraceContext);
7416
+ poolStreamProvider.setupToolExecutor({
7417
+ customTools: this.getCustomTools(),
7418
+ executeTool: (toolName, params) => this.executeTool(toolName, params, {
7419
+ disableToolCache: options.disableToolCache,
7420
+ }),
7421
+ }, "NeuroLink.createMCPStream");
7422
+ const poolStreamResult = await poolStreamProvider.stream({
7423
+ ...options,
7424
+ provider: poolStreamProviderName,
7425
+ model: poolStreamModel,
7426
+ region: poolStreamRegion,
7427
+ systemPrompt: enhancedSystemPrompt,
7428
+ conversationMessages,
7429
+ });
7430
+ logger.debug("[createMCPStream] ModelPool stream handle obtained", {
7431
+ provider: poolStreamProviderName,
7432
+ });
7433
+ // Wrap the stream so we can record success/failure based on what
7434
+ // actually happens during consumption, not merely on obtaining the
7435
+ // handle. Recording success at handle-acquisition time (before any
7436
+ // tokens are delivered) would mean a mid-stream provider drop is
7437
+ // never reflected as a cooldown.
7438
+ const capturedMember = streamMember;
7439
+ const wrappedStream = (async function* () {
7440
+ try {
7441
+ yield* poolStreamResult.stream;
7442
+ streamPool.recordSuccess(capturedMember);
7443
+ }
7444
+ catch (streamConsumeErr) {
7445
+ streamPool.recordFailure(capturedMember, classifyProviderError(streamConsumeErr));
7446
+ throw streamConsumeErr;
7447
+ }
7448
+ })();
7449
+ return {
7450
+ stream: wrappedStream,
7451
+ provider: poolStreamProviderName,
7452
+ usage: poolStreamResult.usage,
7453
+ model: poolStreamResult.model || poolStreamModel,
7454
+ finishReason: poolStreamResult.finishReason,
7455
+ toolCalls: poolStreamResult.toolCalls ?? [],
7456
+ toolResults: poolStreamResult.toolResults ?? [],
7457
+ analytics: poolStreamResult.analytics,
7458
+ };
7459
+ }
7460
+ catch (poolStreamError) {
7461
+ if (isAbortError(poolStreamError)) {
7462
+ throw poolStreamError;
7463
+ }
7464
+ if (isNonRetryableProviderError(poolStreamError)) {
7465
+ const poolStreamErrMsg = poolStreamError instanceof Error
7466
+ ? poolStreamError.message
7467
+ : String(poolStreamError);
7468
+ streamPool.recordFailure(streamMember, classifyProviderError(poolStreamError));
7469
+ // Wrap in a plain Error so runWithFallbackOrchestration /
7470
+ // streamWithIterationFallback does not re-activate the modelChain
7471
+ // layer on top of an already-exhausted pool.
7472
+ throw new Error(`[ModelPool] non-retryable: ${poolStreamErrMsg}`, {
7473
+ cause: poolStreamError,
7474
+ });
7475
+ }
7476
+ streamPool.recordFailure(streamMember, classifyProviderError(poolStreamError));
7477
+ streamLastError =
7478
+ poolStreamError instanceof Error
7479
+ ? poolStreamError
7480
+ : new Error(String(poolStreamError));
7481
+ logger.warn(`[createMCPStream] ModelPool: member ${poolStreamProviderName} failed`, { error: streamLastError.message });
7482
+ }
7483
+ }
7484
+ // All pool stream members failed.
7485
+ // Wrap in a plain Error (not a typed error class) so that the
7486
+ // surrounding fallback orchestrator does not start a second retry layer
7487
+ // over the already-exhausted pool.
7488
+ throw new Error(`[ModelPool] all stream members failed: ${streamLastError?.message ?? "no stream members available"}`);
7489
+ }
7490
+ // ─── End ModelPool stream path ────────────────────────────────────────────
7122
7491
  // 🔧 FIX: Pass enhanced system prompt to real streaming
7123
7492
  // Tools will be accessed through the streamText call in executeStream
7124
7493
  const streamResult = await provider.stream({