@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.
- package/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +341 -341
- package/dist/core/toolRouting.d.ts +9 -0
- package/dist/core/toolRouting.js +178 -1
- package/dist/core/toolRoutingEmbedding.d.ts +112 -0
- package/dist/core/toolRoutingEmbedding.js +322 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +35 -0
- package/dist/lib/core/toolRouting.d.ts +9 -0
- package/dist/lib/core/toolRouting.js +178 -1
- package/dist/lib/core/toolRoutingEmbedding.d.ts +112 -0
- package/dist/lib/core/toolRoutingEmbedding.js +323 -0
- package/dist/lib/index.d.ts +31 -0
- package/dist/lib/index.js +35 -0
- package/dist/lib/neurolink.d.ts +22 -0
- package/dist/lib/neurolink.js +544 -175
- 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 +18 -0
- package/dist/lib/types/index.d.ts +2 -0
- package/dist/lib/types/index.js +4 -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/toolRouting.d.ts +171 -0
- package/dist/lib/utils/providerErrorClassification.d.ts +24 -0
- package/dist/lib/utils/providerErrorClassification.js +89 -0
- package/dist/neurolink.d.ts +22 -0
- package/dist/neurolink.js +544 -175
- 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 +18 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +4 -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/toolRouting.d.ts +171 -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,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
|
-
//
|
|
5789
|
-
|
|
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
|
-
|
|
5794
|
-
|
|
5795
|
-
|
|
5796
|
-
|
|
5797
|
-
|
|
5798
|
-
|
|
5799
|
-
|
|
5800
|
-
|
|
5801
|
-
|
|
5802
|
-
|
|
5803
|
-
|
|
5804
|
-
|
|
5805
|
-
|
|
5806
|
-
|
|
5807
|
-
|
|
5808
|
-
|
|
5809
|
-
|
|
5810
|
-
|
|
5811
|
-
|
|
5812
|
-
|
|
5813
|
-
|
|
5814
|
-
|
|
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
|
-
|
|
5818
|
-
|
|
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
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
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
|
-
//
|
|
5870
|
-
|
|
5871
|
-
//
|
|
5872
|
-
|
|
5873
|
-
// the
|
|
5874
|
-
//
|
|
5875
|
-
//
|
|
5876
|
-
//
|
|
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
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
|
|
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:
|
|
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({
|