@juspay/neurolink 9.76.0 → 9.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/browser/neurolink.min.js +341 -341
  3. package/dist/index.d.ts +30 -0
  4. package/dist/index.js +33 -0
  5. package/dist/lib/index.d.ts +30 -0
  6. package/dist/lib/index.js +33 -0
  7. package/dist/lib/neurolink.d.ts +21 -0
  8. package/dist/lib/neurolink.js +367 -90
  9. package/dist/lib/routing/index.d.ts +7 -0
  10. package/dist/lib/routing/index.js +8 -0
  11. package/dist/lib/routing/modelPool.d.ts +83 -0
  12. package/dist/lib/routing/modelPool.js +243 -0
  13. package/dist/lib/routing/requestRouter.d.ts +30 -0
  14. package/dist/lib/routing/requestRouter.js +81 -0
  15. package/dist/lib/types/config.d.ts +18 -0
  16. package/dist/lib/types/index.d.ts +2 -0
  17. package/dist/lib/types/index.js +4 -0
  18. package/dist/lib/types/modelPool.d.ts +47 -0
  19. package/dist/lib/types/modelPool.js +11 -0
  20. package/dist/lib/types/requestRouter.d.ts +75 -0
  21. package/dist/lib/types/requestRouter.js +16 -0
  22. package/dist/lib/utils/providerErrorClassification.d.ts +24 -0
  23. package/dist/lib/utils/providerErrorClassification.js +89 -0
  24. package/dist/neurolink.d.ts +21 -0
  25. package/dist/neurolink.js +367 -90
  26. package/dist/routing/index.d.ts +7 -0
  27. package/dist/routing/index.js +7 -0
  28. package/dist/routing/modelPool.d.ts +83 -0
  29. package/dist/routing/modelPool.js +242 -0
  30. package/dist/routing/requestRouter.d.ts +30 -0
  31. package/dist/routing/requestRouter.js +80 -0
  32. package/dist/types/config.d.ts +18 -0
  33. package/dist/types/index.d.ts +2 -0
  34. package/dist/types/index.js +4 -0
  35. package/dist/types/modelPool.d.ts +47 -0
  36. package/dist/types/modelPool.js +10 -0
  37. package/dist/types/requestRouter.d.ts +75 -0
  38. package/dist/types/requestRouter.js +15 -0
  39. package/dist/utils/providerErrorClassification.d.ts +24 -0
  40. package/dist/utils/providerErrorClassification.js +88 -0
  41. package/package.json +4 -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
  *
@@ -458,6 +376,12 @@ export class NeuroLink {
458
376
  // Curator P2-3: instance-level fallback policy. Read by
459
377
  // runWithFallbackOrchestration on model-access-denied.
460
378
  fallbackConfig = {};
379
+ // ModelPool: opt-in multi-provider failover with per-member cooldown.
380
+ // Built once from config.modelPool in the constructor; null when not configured.
381
+ modelPool;
382
+ // RequestRouter: pluggable pre-call provider/model selector.
383
+ // Stored directly from config.requestRouter; null when not configured.
384
+ requestRouter;
461
385
  /**
462
386
  * Merge instance-level credentials with per-call credentials.
463
387
  *
@@ -862,6 +786,10 @@ export class NeuroLink {
862
786
  if (config?.toolDedup) {
863
787
  this.toolDedupConfig = { ...config.toolDedup };
864
788
  }
789
+ // ModelPool: build one instance from config; null when not configured.
790
+ this.modelPool = config?.modelPool ? new ModelPool(config.modelPool) : null;
791
+ // RequestRouter: store the host-supplied function; null when not configured.
792
+ this.requestRouter = config?.requestRouter ?? null;
865
793
  logger.setEventEmitter(this.emitter);
866
794
  // Read tool cache duration from environment variables, with a default
867
795
  const cacheDurationEnv = process.env.NEUROLINK_TOOL_CACHE_DURATION;
@@ -3172,6 +3100,11 @@ Current user's request: ${currentInput}`;
3172
3100
  async runStandardGenerateRequest(options, originalPrompt, generateSpan) {
3173
3101
  const startTime = Date.now();
3174
3102
  await this.maybeApplyGenerateOrchestration(options);
3103
+ // Pre-call request router: opt-in, only when the caller did not set both
3104
+ // provider+model. Fails open (any router error leaves options unchanged).
3105
+ await this.applyRequestRouter(options, options.input?.text ?? originalPrompt ?? "", !options.disableTools &&
3106
+ (!!(options.tools && Object.keys(options.tools).length > 0) ||
3107
+ this.getCustomTools().size > 0), !!(options.input?.images && options.input.images.length > 0), options.thinkingConfig?.thinkingLevel);
3175
3108
  this.emitter.emit("generation:start", {
3176
3109
  provider: options.provider || "auto",
3177
3110
  timestamp: startTime,
@@ -3280,6 +3213,94 @@ Current user's request: ${currentInput}`;
3280
3213
  });
3281
3214
  }
3282
3215
  }
3216
+ /**
3217
+ * Applies the host-configured `requestRouter` to `options` in place.
3218
+ *
3219
+ * The router is skipped when:
3220
+ * - no `requestRouter` is configured on this instance, or
3221
+ * - the caller explicitly set both `options.provider` AND `options.model`
3222
+ * (we only skip the router if BOTH are set; a caller setting only one
3223
+ * still lets the router fill in the other field).
3224
+ *
3225
+ * Fails open: any router error is logged at WARN level and the call
3226
+ * continues with the original options unmodified.
3227
+ *
3228
+ * @param options — the mutable options object (generate or stream).
3229
+ * @param promptText — the text prompt used to build the RouterInputContext.
3230
+ * @param hasTools — true when at least one tool is available for this call.
3231
+ * @param requiresVision — true when the call includes image attachments.
3232
+ * @param thinkingLevel — optional thinking level string from the call options.
3233
+ */
3234
+ async applyRequestRouter(options, promptText, hasTools, requiresVision, thinkingLevel) {
3235
+ if (!this.requestRouter) {
3236
+ return;
3237
+ }
3238
+ // Skip if the caller explicitly set both provider AND model — they know
3239
+ // what they want; the router should not override an intentional choice.
3240
+ if (options.provider && options.model) {
3241
+ return;
3242
+ }
3243
+ // Skip when a ModelPool is also configured: the pool unconditionally
3244
+ // overrides provider+model for each member it selects. Allowing the
3245
+ // router to run first would set options.provider/model to a value the
3246
+ // pool immediately clobbers, creating silent ownership confusion and
3247
+ // making the pool bypass the "user didn't set provider+model" guard above
3248
+ // on subsequent attempts.
3249
+ if (this.modelPool) {
3250
+ logger.debug("[NeuroLink] applyRequestRouter: skipped — modelPool takes precedence");
3251
+ return;
3252
+ }
3253
+ const ctx = {
3254
+ prompt: promptText,
3255
+ // Cheap token estimate: ~4 chars/token.
3256
+ estimatedInputTokens: Math.ceil(promptText.length / 4),
3257
+ hasTools,
3258
+ requiresVision,
3259
+ thinkingLevel,
3260
+ };
3261
+ try {
3262
+ const decision = await this.requestRouter(ctx);
3263
+ // Apply provider/model/region as a coherent set: when the router supplies
3264
+ // a provider (the caller didn't pin one), take its model and region too.
3265
+ // If the caller pinned the provider but not the model, only apply the
3266
+ // router's model when it targets the same provider.
3267
+ if (decision.provider && !options.provider) {
3268
+ options.provider = decision.provider;
3269
+ // Adopt the router's model and region only when they belong to the
3270
+ // provider the router just selected — never mix a router model onto a
3271
+ // caller-pinned different provider.
3272
+ if (decision.model && !options.model) {
3273
+ options.model = decision.model;
3274
+ }
3275
+ if (decision.region && !options.region) {
3276
+ options.region = decision.region;
3277
+ }
3278
+ }
3279
+ else if (!decision.provider) {
3280
+ // Router returned a no-op or model-only decision.
3281
+ if (decision.model && !options.model) {
3282
+ options.model = decision.model;
3283
+ }
3284
+ if (decision.region && !options.region) {
3285
+ options.region = decision.region;
3286
+ }
3287
+ }
3288
+ // If decision.provider differs from the caller-pinned options.provider,
3289
+ // we skip the router result entirely to avoid an incoherent pairing.
3290
+ if (decision.reason) {
3291
+ logger.debug("[NeuroLink] requestRouter decision", {
3292
+ reason: decision.reason,
3293
+ provider: options.provider,
3294
+ model: options.model,
3295
+ });
3296
+ }
3297
+ }
3298
+ catch (routerErr) {
3299
+ logger.warn("[NeuroLink] requestRouter threw — proceeding unrouted", {
3300
+ error: routerErr instanceof Error ? routerErr.message : String(routerErr),
3301
+ });
3302
+ }
3303
+ }
3283
3304
  async prepareGenerateAugmentations(options) {
3284
3305
  if (options.rag?.files?.length) {
3285
3306
  try {
@@ -4961,6 +4982,149 @@ Current user's request: ${currentInput}`;
4961
4982
  tryProviders,
4962
4983
  allowFallback: !requestedProvider || !!preferredOrchestrated,
4963
4984
  });
4985
+ // ─── ModelPool path ──────────────────────────────────────────────────────
4986
+ // When a ModelPool is configured, source the candidate sequence from the
4987
+ // pool instead of (or in addition to) the static tryProviders list.
4988
+ // Preserves the existing isNonRetryableProviderError short-circuit and
4989
+ // AbortError propagation semantics. Falls through to the standard path
4990
+ // when no pool is configured.
4991
+ if (this.modelPool) {
4992
+ const pool = this.modelPool;
4993
+ const maxPoolAttempts = pool.maxAttempts;
4994
+ const triedKeys = new Set();
4995
+ let poolLastError = null;
4996
+ for (let attempt = 0; attempt < maxPoolAttempts; attempt++) {
4997
+ if (options.abortSignal?.aborted) {
4998
+ throw new DOMException("The operation was aborted", "AbortError");
4999
+ }
5000
+ const member = pool.selectNext(triedKeys);
5001
+ if (!member) {
5002
+ // All available members exhausted.
5003
+ break;
5004
+ }
5005
+ triedKeys.add(pool.memberKey(member));
5006
+ // Temporarily override provider/model/region from the pool member.
5007
+ const savedProvider = options.provider;
5008
+ const savedModel = options.model;
5009
+ const savedRegion = options.region;
5010
+ options.provider = member.provider;
5011
+ // When the member omits model/region, clear inherited values so the
5012
+ // provider's own default is used rather than a mismatched caller value.
5013
+ options.model = member.model ?? undefined;
5014
+ options.region = member.region ?? undefined;
5015
+ const poolProviderName = member.provider;
5016
+ logger.debug(`[${functionTag}] ModelPool: attempting member`, {
5017
+ provider: poolProviderName,
5018
+ model: member.model ?? options.model,
5019
+ attempt,
5020
+ });
5021
+ try {
5022
+ // Get conversation messages for context (use pre-compacted if provided)
5023
+ const poolOptionsWithMessages = options;
5024
+ const poolConversationMessages = poolOptionsWithMessages
5025
+ .conversationMessages?.length
5026
+ ? poolOptionsWithMessages.conversationMessages
5027
+ : await getConversationMessages(this.conversationMemory, options);
5028
+ const poolProvider = await AIProviderFactory.createProvider(poolProviderName, options.model, !options.disableTools, this, options.region, this.resolveCredentials(options.credentials));
5029
+ poolProvider.setTraceContext(this._metricsTraceContext);
5030
+ poolProvider.setupToolExecutor({
5031
+ customTools: this.getCustomTools(),
5032
+ executeTool: (toolName, params) => this.executeTool(toolName, params, {
5033
+ disableToolCache: options.disableToolCache,
5034
+ }),
5035
+ }, functionTag);
5036
+ const poolResult = await poolProvider.generate({
5037
+ ...options,
5038
+ conversationMessages: poolConversationMessages,
5039
+ });
5040
+ if (!poolResult) {
5041
+ throw new Error(`ModelPool: provider ${poolProviderName} returned null result`);
5042
+ }
5043
+ pool.recordSuccess(member);
5044
+ const responseTime = Date.now() - startTime;
5045
+ // Restore original options fields so callers see a clean object.
5046
+ options.provider = savedProvider;
5047
+ options.model = savedModel;
5048
+ options.region = savedRegion;
5049
+ return {
5050
+ content: poolResult.content || "",
5051
+ provider: poolProviderName,
5052
+ model: poolResult.model,
5053
+ usage: poolResult.usage,
5054
+ responseTime,
5055
+ finishReason: poolResult.finishReason,
5056
+ toolsUsed: poolResult.toolsUsed || [],
5057
+ toolExecutions: poolResult.toolExecutions?.map((te) => {
5058
+ const t = te;
5059
+ return {
5060
+ ...te,
5061
+ toolName: te.name,
5062
+ executionTime: typeof t.executionTime === "number"
5063
+ ? t.executionTime
5064
+ : typeof t.duration === "number"
5065
+ ? t.duration
5066
+ : 0,
5067
+ success: typeof t.success === "boolean"
5068
+ ? t.success
5069
+ : t.status === "success",
5070
+ };
5071
+ }),
5072
+ enhancedWithTools: !!poolResult.toolExecutions?.length,
5073
+ analytics: poolResult.analytics,
5074
+ evaluation: poolResult.evaluation,
5075
+ audio: poolResult.audio,
5076
+ video: poolResult.video,
5077
+ avatar: poolResult.avatar,
5078
+ music: poolResult.music,
5079
+ ppt: poolResult.ppt,
5080
+ imageOutput: poolResult.imageOutput,
5081
+ reasoning: poolResult.reasoning,
5082
+ reasoningTokens: poolResult.reasoningTokens,
5083
+ _generationEndEmitted: poolResult._generationEndEmitted,
5084
+ };
5085
+ }
5086
+ catch (poolError) {
5087
+ // Restore options unconditionally on failure.
5088
+ options.provider = savedProvider;
5089
+ options.model = savedModel;
5090
+ options.region = savedRegion;
5091
+ if (isAbortError(poolError)) {
5092
+ throw poolError;
5093
+ }
5094
+ if (isNonRetryableProviderError(poolError)) {
5095
+ const poolErrMsg = poolError instanceof Error
5096
+ ? poolError.message
5097
+ : String(poolError);
5098
+ logger.warn(`[${functionTag}] ModelPool: non-retryable error from ${poolProviderName}, stopping pool`, { error: poolErrMsg });
5099
+ pool.recordFailure(member, classifyProviderError(poolError));
5100
+ // Wrap in a plain Error so runWithFallbackOrchestration does not
5101
+ // mistake this for a ModelAccessDeniedError and start a second
5102
+ // retry layer on top of the already-exhausted pool.
5103
+ throw new Error(`[ModelPool] non-retryable: ${poolErrMsg}`, {
5104
+ cause: poolError,
5105
+ });
5106
+ }
5107
+ pool.recordFailure(member, classifyProviderError(poolError));
5108
+ poolLastError =
5109
+ poolError instanceof Error
5110
+ ? poolError
5111
+ : new Error(String(poolError));
5112
+ logger.warn(`[${functionTag}] ModelPool: member ${poolProviderName} failed`, { error: poolLastError.message });
5113
+ }
5114
+ }
5115
+ // All pool members failed or were exhausted.
5116
+ const poolResponseTime = Date.now() - startTime;
5117
+ logger.error(`[${functionTag}] ModelPool: all members failed`, {
5118
+ triedKeys: Array.from(triedKeys),
5119
+ lastError: poolLastError?.message,
5120
+ responseTime: poolResponseTime,
5121
+ });
5122
+ // Wrap in a plain Error (not a typed error class) so that
5123
+ // runWithFallbackOrchestration does not re-activate the modelChain /
5124
+ // providerFallback layer on top of an already-exhausted pool.
5125
+ throw new Error(`[ModelPool] all members failed: ${poolLastError?.message ?? "no members available"}`);
5126
+ }
5127
+ // ─── End ModelPool path ───────────────────────────────────────────────────
4964
5128
  let lastError = null;
4965
5129
  // Try each provider in order
4966
5130
  for (const providerName of tryProviders) {
@@ -5659,6 +5823,11 @@ Current user's request: ${currentInput}`;
5659
5823
  // classify correctly. After the workflow short-circuit, so workflow
5660
5824
  // streams skip it.
5661
5825
  await context.with(streamSpanContext, () => this.setLangfuseContextFromOptions(options, () => this.applyToolRoutingExclusions(options, originalPrompt)));
5826
+ // Pre-call request router for stream: opt-in, fails open.
5827
+ await this.applyRequestRouter(options, originalPrompt, !options.disableTools &&
5828
+ (!!(options.tools && Object.keys(options.tools).length > 0) ||
5829
+ this.getCustomTools().size > 0), !!(options.input?.images && options.input.images.length > 0), options
5830
+ .thinking?.thinkingLevel);
5662
5831
  // TTS Mode 2 deferred: stream() emits text first, then synthesizes the
5663
5832
  // accumulated response into a single audio chunk at end-of-stream and
5664
5833
  // resolves `streamResult.audio` with the same TTSResult. The resolver is
@@ -7119,6 +7288,114 @@ Current user's request: ${currentInput}`;
7119
7288
  }
7120
7289
  }
7121
7290
  }
7291
+ // ─── ModelPool stream path ────────────────────────────────────────────────
7292
+ // When a pool is configured, replaces the single provider.stream() call
7293
+ // with a retry loop over pool members. Compaction/budget-check above ran
7294
+ // once on the initially-selected `providerName`; the pool may redirect to
7295
+ // a different provider, but those checks are conservative enough that the
7296
+ // redirected provider will also accept the payload (if it has a smaller
7297
+ // window, the budget check would have already thrown before we get here).
7298
+ // On exhaustion, throws the last member error — does NOT fall through to
7299
+ // the static provider path below.
7300
+ if (this.modelPool) {
7301
+ const streamPool = this.modelPool;
7302
+ const maxStreamAttempts = streamPool.maxAttempts;
7303
+ const streamTriedKeys = new Set();
7304
+ let streamLastError = null;
7305
+ for (let attempt = 0; attempt < maxStreamAttempts; attempt++) {
7306
+ if (options.abortSignal?.aborted) {
7307
+ throw new DOMException("The operation was aborted", "AbortError");
7308
+ }
7309
+ const streamMember = streamPool.selectNext(streamTriedKeys);
7310
+ if (!streamMember) {
7311
+ break;
7312
+ }
7313
+ streamTriedKeys.add(streamPool.memberKey(streamMember));
7314
+ // Override provider/model/region from the pool member.
7315
+ // When the member omits model/region, use undefined (provider default)
7316
+ // rather than inheriting the caller's value for a different provider.
7317
+ const poolStreamProviderName = streamMember.provider;
7318
+ const poolStreamModel = streamMember.model ?? undefined;
7319
+ const poolStreamRegion = streamMember.region ?? undefined;
7320
+ logger.debug(`[createMCPStream] ModelPool: attempting member ${poolStreamProviderName}`, { model: poolStreamModel, attempt });
7321
+ try {
7322
+ const poolStreamProvider = await AIProviderFactory.createProvider(poolStreamProviderName, poolStreamModel, !options.disableTools, this, poolStreamRegion, this.resolveCredentials(options.credentials));
7323
+ poolStreamProvider.setTraceContext(this._metricsTraceContext);
7324
+ poolStreamProvider.setupToolExecutor({
7325
+ customTools: this.getCustomTools(),
7326
+ executeTool: (toolName, params) => this.executeTool(toolName, params, {
7327
+ disableToolCache: options.disableToolCache,
7328
+ }),
7329
+ }, "NeuroLink.createMCPStream");
7330
+ const poolStreamResult = await poolStreamProvider.stream({
7331
+ ...options,
7332
+ provider: poolStreamProviderName,
7333
+ model: poolStreamModel,
7334
+ region: poolStreamRegion,
7335
+ systemPrompt: enhancedSystemPrompt,
7336
+ conversationMessages,
7337
+ });
7338
+ logger.debug("[createMCPStream] ModelPool stream handle obtained", {
7339
+ provider: poolStreamProviderName,
7340
+ });
7341
+ // Wrap the stream so we can record success/failure based on what
7342
+ // actually happens during consumption, not merely on obtaining the
7343
+ // handle. Recording success at handle-acquisition time (before any
7344
+ // tokens are delivered) would mean a mid-stream provider drop is
7345
+ // never reflected as a cooldown.
7346
+ const capturedMember = streamMember;
7347
+ const wrappedStream = (async function* () {
7348
+ try {
7349
+ yield* poolStreamResult.stream;
7350
+ streamPool.recordSuccess(capturedMember);
7351
+ }
7352
+ catch (streamConsumeErr) {
7353
+ streamPool.recordFailure(capturedMember, classifyProviderError(streamConsumeErr));
7354
+ throw streamConsumeErr;
7355
+ }
7356
+ })();
7357
+ return {
7358
+ stream: wrappedStream,
7359
+ provider: poolStreamProviderName,
7360
+ usage: poolStreamResult.usage,
7361
+ model: poolStreamResult.model || poolStreamModel,
7362
+ finishReason: poolStreamResult.finishReason,
7363
+ toolCalls: poolStreamResult.toolCalls ?? [],
7364
+ toolResults: poolStreamResult.toolResults ?? [],
7365
+ analytics: poolStreamResult.analytics,
7366
+ };
7367
+ }
7368
+ catch (poolStreamError) {
7369
+ if (isAbortError(poolStreamError)) {
7370
+ throw poolStreamError;
7371
+ }
7372
+ if (isNonRetryableProviderError(poolStreamError)) {
7373
+ const poolStreamErrMsg = poolStreamError instanceof Error
7374
+ ? poolStreamError.message
7375
+ : String(poolStreamError);
7376
+ streamPool.recordFailure(streamMember, classifyProviderError(poolStreamError));
7377
+ // Wrap in a plain Error so runWithFallbackOrchestration /
7378
+ // streamWithIterationFallback does not re-activate the modelChain
7379
+ // layer on top of an already-exhausted pool.
7380
+ throw new Error(`[ModelPool] non-retryable: ${poolStreamErrMsg}`, {
7381
+ cause: poolStreamError,
7382
+ });
7383
+ }
7384
+ streamPool.recordFailure(streamMember, classifyProviderError(poolStreamError));
7385
+ streamLastError =
7386
+ poolStreamError instanceof Error
7387
+ ? poolStreamError
7388
+ : new Error(String(poolStreamError));
7389
+ logger.warn(`[createMCPStream] ModelPool: member ${poolStreamProviderName} failed`, { error: streamLastError.message });
7390
+ }
7391
+ }
7392
+ // All pool stream members failed.
7393
+ // Wrap in a plain Error (not a typed error class) so that the
7394
+ // surrounding fallback orchestrator does not start a second retry layer
7395
+ // over the already-exhausted pool.
7396
+ throw new Error(`[ModelPool] all stream members failed: ${streamLastError?.message ?? "no stream members available"}`);
7397
+ }
7398
+ // ─── End ModelPool stream path ────────────────────────────────────────────
7122
7399
  // 🔧 FIX: Pass enhanced system prompt to real streaming
7123
7400
  // Tools will be accessed through the streamText call in executeStream
7124
7401
  const streamResult = await provider.stream({
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Routing subsystem public API.
3
+ *
4
+ * Exports runtime values only — types are exported from src/lib/types/.
5
+ */
6
+ export { classifyProviderError, ModelPool } from "./modelPool.js";
7
+ export { createDefaultRequestRouter } from "./requestRouter.js";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Routing subsystem public API.
3
+ *
4
+ * Exports runtime values only — types are exported from src/lib/types/.
5
+ */
6
+ export { classifyProviderError, ModelPool } from "./modelPool.js";
7
+ export { createDefaultRequestRouter } from "./requestRouter.js";
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,83 @@
1
+ /**
2
+ * ModelPool runtime — multi-provider failover with per-member cooldown.
3
+ *
4
+ * This module is PURE (no provider imports, no circular dependencies).
5
+ * It works exclusively with provider NAMES + (provider, model, region) tuples;
6
+ * the existing AIProviderFactory creates actual provider instances.
7
+ *
8
+ * Usage:
9
+ * const pool = new ModelPool({ members: [...], strategy: "round-robin" });
10
+ * const member = pool.selectNext();
11
+ * try {
12
+ * await callProvider(member);
13
+ * pool.recordSuccess(member);
14
+ * } catch (err) {
15
+ * pool.recordFailure(member, classifyProviderError(err));
16
+ * // try next member ...
17
+ * }
18
+ */
19
+ import type { ModelPoolConfig, ModelPoolMember, ProviderErrorClass } from "../types/index.js";
20
+ /**
21
+ * Classify a provider error into a coarse `ProviderErrorClass`.
22
+ *
23
+ * Rules (checked in order):
24
+ * 1. HTTP 429 or message pattern → "rate_limit"
25
+ * 2. HTTP 401/403, access-denied pattern, or auth keywords → "auth"
26
+ * 3. Context-window / token-limit message → "context_window"
27
+ * 4. HTTP 5xx or server-error message → "server"
28
+ * 5. Network connectivity error → "network"
29
+ * 6. Everything else → "unknown"
30
+ */
31
+ export declare function classifyProviderError(error: unknown): ProviderErrorClass;
32
+ /**
33
+ * Multi-provider pool with per-member cooldown and strategy-based selection.
34
+ *
35
+ * All state (cooldowns, cursor) is instance-local and resets on construction.
36
+ * Thread safety is not required — Node.js is single-threaded for async work.
37
+ */
38
+ export declare class ModelPool {
39
+ private readonly config;
40
+ private readonly strategy;
41
+ private readonly cooldownMs;
42
+ private readonly state;
43
+ private cursor;
44
+ private readonly now;
45
+ constructor(config: ModelPoolConfig, injectors?: {
46
+ now?: () => number;
47
+ });
48
+ /**
49
+ * The maximum number of attempts per call (pool config value or member count).
50
+ * Used by callers that drive the retry loop externally.
51
+ */
52
+ get maxAttempts(): number;
53
+ /**
54
+ * Returns a stable string key for a pool member.
55
+ * Format: `${provider}:${model ?? "*"}:${region ?? "*"}`
56
+ */
57
+ memberKey(member: ModelPoolMember): string;
58
+ /** Returns members whose cooldown has expired (or were never cooled). */
59
+ availableMembers(): ModelPoolMember[];
60
+ /**
61
+ * Selects the next member to try according to the configured strategy.
62
+ *
63
+ * @param excludedKeys — keys of members already attempted this call.
64
+ * @returns the chosen member, or undefined when all members are exhausted.
65
+ */
66
+ selectNext(excludedKeys?: Set<string>): ModelPoolMember | undefined;
67
+ /**
68
+ * Records a provider failure, setting a cooldown appropriate for the error class.
69
+ *
70
+ * - Retryable classes (rate_limit, server, network, unknown): timed cooldown
71
+ * for `cooldownMs` so the member can recover and be retried later.
72
+ * - Non-retryable classes (auth, context_window): permanent cooldown for the
73
+ * lifetime of this ModelPool instance, because these errors are structural
74
+ * and will not resolve between calls (wrong credentials, model not available
75
+ * in team whitelist, payload exceeds the model's context window).
76
+ */
77
+ recordFailure(member: ModelPoolMember, errorClass: ProviderErrorClass): void;
78
+ /**
79
+ * Records a successful response, clearing any existing cooldown for this member
80
+ * so it remains fully available.
81
+ */
82
+ recordSuccess(member: ModelPoolMember): void;
83
+ }