@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
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Embedding-retrieval fast-path for pre-call tool routing (ITEM B / L2).
3
+ *
4
+ * For large tool catalogs, ranking by semantic similarity is far cheaper than
5
+ * an LLM router call and benchmarks at sub-10 ms when embedding vectors are
6
+ * cached. This module is intentionally PURE with no provider imports: the
7
+ * caller injects an `embedFn` so the retriever stays testable and free of
8
+ * circular dependencies.
9
+ *
10
+ * Hybrid scoring formula:
11
+ * score = weights.cosine * cosineSimilarity(queryVec, toolVec)
12
+ * + weights.bm25 * bm25Score(query, toolText)
13
+ *
14
+ * Both components are independently normalized to [0, 1] before combination
15
+ * so neither scale dominates.
16
+ *
17
+ * Fail-safe contract:
18
+ * `ToolEmbeddingIndex.rank()` and `selectRelevantToolNames()` propagate any
19
+ * error thrown by the injected `embedFn`. The CALLER (tool-routing
20
+ * orchestration) is responsible for catching that error and degrading
21
+ * gracefully to the LLM-router / server-granularity path.
22
+ *
23
+ * Determinism:
24
+ * All public functions are deterministic given the same inputs and embedFn.
25
+ * No Math.random(), no argless Date, no global mutable state outside the
26
+ * instance-level vector cache (which is keyed by text content).
27
+ */
28
+ import { withTimeout } from "../utils/async/index.js";
29
+ // ---------------------------------------------------------------------------
30
+ // Constants
31
+ // ---------------------------------------------------------------------------
32
+ const DEFAULT_WEIGHTS = { cosine: 0.8, bm25: 0.2 };
33
+ /** BM25 free-parameter k1 — controls term-frequency saturation. */
34
+ const BM25_K1 = 1.5;
35
+ /** BM25 free-parameter b — controls document-length normalisation. */
36
+ const BM25_B = 0.75;
37
+ // ---------------------------------------------------------------------------
38
+ // Primitive helpers (pure, no side-effects)
39
+ // ---------------------------------------------------------------------------
40
+ /**
41
+ * Tokenise text for BM25/lexical scoring: lowercase, strip punctuation, split
42
+ * on whitespace, drop empty tokens.
43
+ */
44
+ function tokenize(text) {
45
+ return text
46
+ .toLowerCase()
47
+ .replace(/[^\w\s]/g, " ")
48
+ .split(/\s+/)
49
+ .filter((t) => t.length > 0);
50
+ }
51
+ /**
52
+ * Cosine similarity between two vectors.
53
+ *
54
+ * Returns 0 when either vector is zero-length, has zero magnitude, or the
55
+ * lengths differ — all of which indicate the comparison is meaningless.
56
+ */
57
+ export function cosineSimilarity(a, b) {
58
+ if (a.length === 0 || b.length === 0 || a.length !== b.length) {
59
+ return 0;
60
+ }
61
+ let dot = 0;
62
+ let magA = 0;
63
+ let magB = 0;
64
+ for (let i = 0; i < a.length; i++) {
65
+ dot += a[i] * b[i];
66
+ magA += a[i] * a[i];
67
+ magB += b[i] * b[i];
68
+ }
69
+ if (magA === 0 || magB === 0) {
70
+ return 0;
71
+ }
72
+ return dot / (Math.sqrt(magA) * Math.sqrt(magB));
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // BM25 scorer
76
+ // ---------------------------------------------------------------------------
77
+ /**
78
+ * A minimal, deterministic BM25 corpus over a fixed set of documents.
79
+ * Documents are indexed at construction time; only `score()` is exposed
80
+ * for query-time use.
81
+ *
82
+ * This is a TF-IDF/BM25 hybrid consistent with the InMemoryBM25Index in
83
+ * `src/lib/rag/retrieval/hybridSearch.ts`, adapted for a read-once static
84
+ * corpus (tool descriptions do not change within a turn).
85
+ *
86
+ * `ToolRoutingBm25Doc` is the per-document shape; it lives in
87
+ * `src/lib/types/toolRouting.ts` per Critical Rule 2.
88
+ */
89
+ class Bm25Corpus {
90
+ docs;
91
+ avgDocLength;
92
+ /** IDF per unique token across all documents (computed once). */
93
+ idf;
94
+ constructor(texts) {
95
+ this.docs = texts.map((text) => {
96
+ const tokens = tokenize(text);
97
+ const tf = new Map();
98
+ for (const t of tokens) {
99
+ tf.set(t, (tf.get(t) ?? 0) + 1);
100
+ }
101
+ return { tokens, tf };
102
+ });
103
+ const totalLength = this.docs.reduce((s, d) => s + d.tokens.length, 0);
104
+ this.avgDocLength =
105
+ this.docs.length > 0 ? totalLength / this.docs.length : 1;
106
+ // Precompute IDF for every token that appears in any document.
107
+ const N = this.docs.length;
108
+ const df = new Map();
109
+ for (const doc of this.docs) {
110
+ for (const token of doc.tf.keys()) {
111
+ df.set(token, (df.get(token) ?? 0) + 1);
112
+ }
113
+ }
114
+ this.idf = new Map();
115
+ for (const [token, docFreq] of df) {
116
+ // Robertson/Sparck-Jones IDF (safe against 0):
117
+ // log((N - df + 0.5) / (df + 0.5) + 1)
118
+ this.idf.set(token, Math.log((N - docFreq + 0.5) / (docFreq + 0.5) + 1));
119
+ }
120
+ }
121
+ /**
122
+ * Returns the BM25 score of `docIndex` against `query`.
123
+ * Score is always >= 0. Returns 0 for empty queries or out-of-range indices.
124
+ */
125
+ score(docIndex, query) {
126
+ if (docIndex < 0 || docIndex >= this.docs.length) {
127
+ return 0;
128
+ }
129
+ const queryTokens = tokenize(query);
130
+ if (queryTokens.length === 0) {
131
+ return 0;
132
+ }
133
+ const doc = this.docs[docIndex];
134
+ const docLength = doc.tokens.length;
135
+ let total = 0;
136
+ for (const qt of queryTokens) {
137
+ const tf = doc.tf.get(qt) ?? 0;
138
+ if (tf === 0) {
139
+ continue;
140
+ }
141
+ const idf = this.idf.get(qt) ?? 0;
142
+ // BM25 term contribution
143
+ const numerator = tf * (BM25_K1 + 1);
144
+ const denominator = tf + BM25_K1 * (1 - BM25_B + BM25_B * (docLength / this.avgDocLength));
145
+ total += idf * (numerator / denominator);
146
+ }
147
+ return total;
148
+ }
149
+ }
150
+ // ---------------------------------------------------------------------------
151
+ // Normalisation helper
152
+ // ---------------------------------------------------------------------------
153
+ /**
154
+ * Min-max normalises an array of non-negative scores to [0, 1].
155
+ * Returns an array of zeros when all scores are identical (range === 0).
156
+ */
157
+ function normalizeScores(scores) {
158
+ if (scores.length === 0) {
159
+ return [];
160
+ }
161
+ let min = scores[0] ?? 0;
162
+ let max = scores[0] ?? 0;
163
+ for (let i = 1; i < scores.length; i++) {
164
+ const s = scores[i] ?? 0;
165
+ if (s < min) {
166
+ min = s;
167
+ }
168
+ if (s > max) {
169
+ max = s;
170
+ }
171
+ }
172
+ const range = max - min;
173
+ if (range === 0) {
174
+ return scores.map(() => 0);
175
+ }
176
+ return scores.map((s) => (s - min) / range);
177
+ }
178
+ // ---------------------------------------------------------------------------
179
+ // ToolEmbeddingIndex
180
+ // ---------------------------------------------------------------------------
181
+ /**
182
+ * An in-process index that ranks tool catalog items by hybrid semantic +
183
+ * lexical relevance to a query.
184
+ *
185
+ * Embedding vectors for tool descriptions are computed lazily on the first
186
+ * `rank()` call and cached by description text, so subsequent turns that
187
+ * share the same catalog pay only the cost of embedding the query itself.
188
+ *
189
+ * ### Fail-safe
190
+ * Any error thrown by `embedFn` propagates out of `rank()`. The CALLER must
191
+ * catch it and degrade to the LLM-router path; `ToolEmbeddingIndex` itself
192
+ * never silently swallows embedFn errors.
193
+ *
194
+ * ### Thread-safety
195
+ * The internal cache is a plain `Map`. Node.js is single-threaded so there
196
+ * are no data races, but if the same index instance is used concurrently
197
+ * (e.g. two turns in parallel) both calls will race to populate the cache;
198
+ * the last writer wins (same content either way because `embedFn` is
199
+ * deterministic for a given text).
200
+ */
201
+ export class ToolEmbeddingIndex {
202
+ items;
203
+ embedFn;
204
+ bm25CorpusCache;
205
+ /**
206
+ * Cached embedding vectors keyed by the item's `text` field.
207
+ * Populated on first `rank()` call for texts not yet seen.
208
+ *
209
+ * Callers can share a single Map across multiple index instances (e.g.
210
+ * across turns in the same NeuroLink session) so tool vectors are computed
211
+ * once and reused. Pass the shared Map via the constructor's third argument.
212
+ */
213
+ vectorCache;
214
+ constructor(items, embedFn,
215
+ /**
216
+ * Optional shared vector cache. When supplied, cached vectors from
217
+ * previous turns are reused and any newly-computed vectors are stored
218
+ * into this same Map, making it warm for the next call.
219
+ */
220
+ sharedVectorCache) {
221
+ this.items = items;
222
+ this.embedFn = embedFn;
223
+ this.vectorCache = sharedVectorCache ?? new Map();
224
+ }
225
+ /**
226
+ * Returns the top-K catalog items ranked by hybrid score descending.
227
+ *
228
+ * @throws If `embedFn` throws — propagated verbatim so the caller can fail
229
+ * open. No wrapping, no swallowing.
230
+ */
231
+ async rank(query, opts) {
232
+ if (this.items.length === 0) {
233
+ return [];
234
+ }
235
+ const weights = opts.weights ?? DEFAULT_WEIGHTS;
236
+ // Identify which item texts still need embedding vectors.
237
+ const uncachedTexts = [
238
+ ...new Set(this.items
239
+ .map((item) => item.text)
240
+ .filter((text) => !this.vectorCache.has(text))),
241
+ ];
242
+ if (uncachedTexts.length > 0) {
243
+ // embedFn errors propagate — caller is responsible for fail-open.
244
+ const vectors = await withTimeout(this.embedFn(uncachedTexts), opts.timeoutMs ?? 10000, "ToolEmbeddingIndex.embed");
245
+ for (let i = 0; i < uncachedTexts.length; i++) {
246
+ const vec = vectors[i];
247
+ if (vec !== undefined) {
248
+ this.vectorCache.set(uncachedTexts[i], vec);
249
+ }
250
+ }
251
+ }
252
+ // Embed the query (single call; the query is not cached since it changes
253
+ // every turn — caching query vectors is the caller's responsibility if
254
+ // desired, e.g. via ToolRoutingCache).
255
+ const [queryVector] = await withTimeout(this.embedFn([query]), opts.timeoutMs ?? 10000, "ToolEmbeddingIndex.queryEmbed");
256
+ // Build BM25 corpus lazily; rebuild only when the item texts change.
257
+ const corpusKey = this.items.map((item) => item.text).join("\x00");
258
+ if (!this.bm25CorpusCache || this.bm25CorpusCache.key !== corpusKey) {
259
+ this.bm25CorpusCache = {
260
+ key: corpusKey,
261
+ corpus: new Bm25Corpus(this.items.map((item) => item.text)),
262
+ };
263
+ }
264
+ const bm25Corpus = this.bm25CorpusCache.corpus;
265
+ // Compute raw cosine and BM25 scores for each item.
266
+ const rawCosine = [];
267
+ const rawBm25 = [];
268
+ for (let i = 0; i < this.items.length; i++) {
269
+ const toolVec = this.vectorCache.get(this.items[i].text) ?? [];
270
+ rawCosine.push(cosineSimilarity(queryVector ?? [], toolVec));
271
+ rawBm25.push(bm25Corpus.score(i, query));
272
+ }
273
+ // Normalize each component independently so scales don't interact.
274
+ const normCosine = normalizeScores(rawCosine);
275
+ const normBm25 = normalizeScores(rawBm25);
276
+ // Combine into hybrid score and zip with item names.
277
+ const ranked = this.items.map((item, i) => ({
278
+ name: item.name,
279
+ score: weights.cosine * (normCosine[i] ?? 0) +
280
+ weights.bm25 * (normBm25[i] ?? 0),
281
+ }));
282
+ // Sort descending by score, then by name for a deterministic tie-break.
283
+ ranked.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
284
+ return ranked.slice(0, opts.topK);
285
+ }
286
+ }
287
+ // ---------------------------------------------------------------------------
288
+ // Convenience wrapper
289
+ // ---------------------------------------------------------------------------
290
+ /**
291
+ * Selects the most relevant tool names from a catalog given a query.
292
+ *
293
+ * Creates a temporary `ToolEmbeddingIndex`, runs `rank()`, and returns just
294
+ * the tool names. Use `ToolEmbeddingIndex` directly when you want to reuse
295
+ * cached tool vectors across multiple queries (e.g. multiple turns with the
296
+ * same catalog).
297
+ *
298
+ * @throws If `opts.embedFn` throws — propagated so the caller can degrade to
299
+ * the LLM-router path.
300
+ *
301
+ * @example
302
+ * ```ts
303
+ * const tools = await selectRelevantToolNames(
304
+ * "show me yesterday's sales",
305
+ * catalog.flatMap(server => server.toolNames.map(name => ({
306
+ * name,
307
+ * text: `${server.description} — ${name}`,
308
+ * }))),
309
+ * { topK: 5, embedFn: myEmbedFn },
310
+ * );
311
+ * // => ["analytics_getSales", "analytics_getRevenue", ...]
312
+ * ```
313
+ */
314
+ export async function selectRelevantToolNames(query, items, opts) {
315
+ const index = new ToolEmbeddingIndex(items, opts.embedFn, opts.vectorCache);
316
+ const ranked = await index.rank(query, {
317
+ topK: opts.topK,
318
+ weights: opts.weights,
319
+ timeoutMs: opts.timeoutMs,
320
+ });
321
+ return ranked.map((r) => r.name);
322
+ }
323
+ //# sourceMappingURL=toolRoutingEmbedding.js.map
@@ -219,6 +219,7 @@ export { logger } from "./utils/logger.js";
219
219
  export { getPoolStats } from "./utils/redis.js";
220
220
  export { ToolRoutingCache } from "./core/toolRoutingCache.js";
221
221
  export { resolveToolRoutingExclusions, buildToolRoutingCatalog, buildRoutingQueryFromHistory, DEFAULT_ROUTER_PROMPT_PREFIX, } from "./core/toolRouting.js";
222
+ export { cosineSimilarity, ToolEmbeddingIndex, selectRelevantToolNames, } from "./core/toolRoutingEmbedding.js";
222
223
  export declare function initializeTelemetry(): Promise<boolean>;
223
224
  export declare function getTelemetryStatus(): Promise<{
224
225
  enabled: boolean;
@@ -337,6 +338,36 @@ export { BALANCED_ADAPTIVE_WORKFLOW, createAdaptiveWorkflow, QUALITY_MAX_WORKFLO
337
338
  export { CONSENSUS_3_FAST_WORKFLOW, CONSENSUS_3_WORKFLOW, createConsensus3WithPrompt, } from "./workflow/workflows/consensusWorkflow.js";
338
339
  export { AGGRESSIVE_FALLBACK_WORKFLOW, FAST_FALLBACK_WORKFLOW, } from "./workflow/workflows/fallbackWorkflow.js";
339
340
  export { createMultiJudgeWorkflow, MULTI_JUDGE_3_WORKFLOW, MULTI_JUDGE_5_WORKFLOW, } from "./workflow/workflows/multiJudgeWorkflow.js";
341
+ /**
342
+ * ModelPool and RequestRouter — opt-in multi-provider failover with
343
+ * error-class-aware cooldown, and a pluggable pre-call provider/model router.
344
+ *
345
+ * @example ModelPool
346
+ * ```typescript
347
+ * import { ModelPool, classifyProviderError } from '@juspay/neurolink';
348
+ *
349
+ * const pool = new ModelPool({
350
+ * members: [
351
+ * { provider: 'anthropic', model: 'claude-sonnet-4-5' },
352
+ * { provider: 'vertex', model: 'gemini-2.5-flash' },
353
+ * ],
354
+ * strategy: 'priority',
355
+ * cooldownMs: 30_000,
356
+ * });
357
+ * ```
358
+ *
359
+ * @example RequestRouter
360
+ * ```typescript
361
+ * import { createDefaultRequestRouter } from '@juspay/neurolink';
362
+ *
363
+ * const router = createDefaultRequestRouter({
364
+ * visionTier: { provider: 'vertex', model: 'gemini-2.5-pro' },
365
+ * largeTier: { provider: 'anthropic', model: 'claude-opus-4-5' },
366
+ * smallTier: { provider: 'anthropic', model: 'claude-haiku-3-5' },
367
+ * });
368
+ * ```
369
+ */
370
+ export { classifyProviderError, ModelPool, createDefaultRequestRouter, } from "./routing/index.js";
340
371
  /**
341
372
  * Server Adapters for exposing NeuroLink as HTTP APIs
342
373
  *
package/dist/lib/index.js CHANGED
@@ -362,6 +362,8 @@ export { getPoolStats } from "./utils/redis.js";
362
362
  export { ToolRoutingCache } from "./core/toolRoutingCache.js";
363
363
  // Pre-call tool routing resolver — exported for host integrations and testing
364
364
  export { resolveToolRoutingExclusions, buildToolRoutingCatalog, buildRoutingQueryFromHistory, DEFAULT_ROUTER_PROMPT_PREFIX, } from "./core/toolRouting.js";
365
+ // Pre-call tool routing — embedding fast-path (ITEM B: L2 semantic retrieval)
366
+ export { cosineSimilarity, ToolEmbeddingIndex, selectRelevantToolNames, } from "./core/toolRoutingEmbedding.js";
365
367
  // ============================================================================
366
368
  // REAL-TIME SERVICES & TELEMETRY - Enterprise Platform Features
367
369
  // ============================================================================
@@ -541,6 +543,39 @@ export { AGGRESSIVE_FALLBACK_WORKFLOW, FAST_FALLBACK_WORKFLOW, } from "./workflo
541
543
  // Pre-built workflows - Multi-judge
542
544
  export { createMultiJudgeWorkflow, MULTI_JUDGE_3_WORKFLOW, MULTI_JUDGE_5_WORKFLOW, } from "./workflow/workflows/multiJudgeWorkflow.js";
543
545
  // ============================================================================
546
+ // MODEL POOL & REQUEST ROUTER - Pluggable pre-call routing + failover
547
+ // ============================================================================
548
+ /**
549
+ * ModelPool and RequestRouter — opt-in multi-provider failover with
550
+ * error-class-aware cooldown, and a pluggable pre-call provider/model router.
551
+ *
552
+ * @example ModelPool
553
+ * ```typescript
554
+ * import { ModelPool, classifyProviderError } from '@juspay/neurolink';
555
+ *
556
+ * const pool = new ModelPool({
557
+ * members: [
558
+ * { provider: 'anthropic', model: 'claude-sonnet-4-5' },
559
+ * { provider: 'vertex', model: 'gemini-2.5-flash' },
560
+ * ],
561
+ * strategy: 'priority',
562
+ * cooldownMs: 30_000,
563
+ * });
564
+ * ```
565
+ *
566
+ * @example RequestRouter
567
+ * ```typescript
568
+ * import { createDefaultRequestRouter } from '@juspay/neurolink';
569
+ *
570
+ * const router = createDefaultRequestRouter({
571
+ * visionTier: { provider: 'vertex', model: 'gemini-2.5-pro' },
572
+ * largeTier: { provider: 'anthropic', model: 'claude-opus-4-5' },
573
+ * smallTier: { provider: 'anthropic', model: 'claude-haiku-3-5' },
574
+ * });
575
+ * ```
576
+ */
577
+ export { classifyProviderError, ModelPool, createDefaultRequestRouter, } from "./routing/index.js";
578
+ // ============================================================================
544
579
  // SERVER ADAPTERS - HTTP API Framework Integration
545
580
  // ============================================================================
546
581
  // Server Types
@@ -102,6 +102,7 @@ export declare class NeuroLink {
102
102
  private conversationMemoryConfig?;
103
103
  private toolRoutingConfig?;
104
104
  private toolRoutingCacheInstance?;
105
+ private toolRoutingVectorCache?;
105
106
  private toolDedupConfig?;
106
107
  private enableOrchestration;
107
108
  private authProvider?;
@@ -109,6 +110,8 @@ export declare class NeuroLink {
109
110
  private authInitPromise?;
110
111
  private credentials?;
111
112
  private readonly fallbackConfig;
113
+ private readonly modelPool;
114
+ private readonly requestRouter;
112
115
  /**
113
116
  * Merge instance-level credentials with per-call credentials.
114
117
  *
@@ -612,6 +615,25 @@ export declare class NeuroLink {
612
615
  private maybeHandleEarlyGenerateResult;
613
616
  private runStandardGenerateRequest;
614
617
  private maybeApplyGenerateOrchestration;
618
+ /**
619
+ * Applies the host-configured `requestRouter` to `options` in place.
620
+ *
621
+ * The router is skipped when:
622
+ * - no `requestRouter` is configured on this instance, or
623
+ * - the caller explicitly set both `options.provider` AND `options.model`
624
+ * (we only skip the router if BOTH are set; a caller setting only one
625
+ * still lets the router fill in the other field).
626
+ *
627
+ * Fails open: any router error is logged at WARN level and the call
628
+ * continues with the original options unmodified.
629
+ *
630
+ * @param options — the mutable options object (generate or stream).
631
+ * @param promptText — the text prompt used to build the RouterInputContext.
632
+ * @param hasTools — true when at least one tool is available for this call.
633
+ * @param requiresVision — true when the call includes image attachments.
634
+ * @param thinkingLevel — optional thinking level string from the call options.
635
+ */
636
+ private applyRequestRouter;
615
637
  private prepareGenerateAugmentations;
616
638
  private buildGenerateTextOptions;
617
639
  private finalizeGenerateRequestResult;