@juspay/neurolink 9.79.3 → 9.80.1

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 (46) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +349 -348
  3. package/dist/cli/factories/commandFactory.js +61 -0
  4. package/dist/cli/utils/classifierRouterFlags.d.ts +19 -0
  5. package/dist/cli/utils/classifierRouterFlags.js +111 -0
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/lib/index.d.ts +1 -1
  9. package/dist/lib/index.js +1 -1
  10. package/dist/lib/neurolink.d.ts +14 -0
  11. package/dist/lib/neurolink.js +131 -0
  12. package/dist/lib/providers/googleVertex.d.ts +3 -1
  13. package/dist/lib/providers/googleVertex.js +160 -38
  14. package/dist/lib/routing/classifierRouter.d.ts +52 -0
  15. package/dist/lib/routing/classifierRouter.js +269 -0
  16. package/dist/lib/routing/classifierStrategies.d.ts +23 -0
  17. package/dist/lib/routing/classifierStrategies.js +156 -0
  18. package/dist/lib/routing/index.d.ts +2 -0
  19. package/dist/lib/routing/index.js +2 -0
  20. package/dist/lib/session/globalSessionState.d.ts +10 -1
  21. package/dist/lib/session/globalSessionState.js +18 -0
  22. package/dist/lib/types/classifierRouter.d.ts +193 -0
  23. package/dist/lib/types/classifierRouter.js +17 -0
  24. package/dist/lib/types/cli.d.ts +27 -3
  25. package/dist/lib/types/config.d.ts +10 -0
  26. package/dist/lib/types/index.d.ts +1 -0
  27. package/dist/lib/types/index.js +2 -0
  28. package/dist/neurolink.d.ts +14 -0
  29. package/dist/neurolink.js +131 -0
  30. package/dist/providers/googleVertex.d.ts +3 -1
  31. package/dist/providers/googleVertex.js +160 -38
  32. package/dist/routing/classifierRouter.d.ts +52 -0
  33. package/dist/routing/classifierRouter.js +268 -0
  34. package/dist/routing/classifierStrategies.d.ts +23 -0
  35. package/dist/routing/classifierStrategies.js +155 -0
  36. package/dist/routing/index.d.ts +2 -0
  37. package/dist/routing/index.js +2 -0
  38. package/dist/session/globalSessionState.d.ts +10 -1
  39. package/dist/session/globalSessionState.js +18 -0
  40. package/dist/types/classifierRouter.d.ts +193 -0
  41. package/dist/types/classifierRouter.js +16 -0
  42. package/dist/types/cli.d.ts +27 -3
  43. package/dist/types/config.d.ts +10 -0
  44. package/dist/types/index.d.ts +1 -0
  45. package/dist/types/index.js +2 -0
  46. package/package.json +2 -1
package/dist/neurolink.js CHANGED
@@ -87,6 +87,7 @@ import { isNonNullObject } from "./utils/typeUtils.js";
87
87
  import { getWorkflow } from "./workflow/core/workflowRegistry.js";
88
88
  import { runWorkflow } from "./workflow/core/workflowRunner.js";
89
89
  import { ModelPool, classifyProviderError } from "./routing/index.js";
90
+ import { ClassifierRouter } from "./routing/classifierRouter.js";
90
91
  import { looksLikeModelAccessDenied as sharedLooksLikeModelAccessDenied, isNonRetryableProviderError as sharedIsNonRetryableProviderError, } from "./utils/providerErrorClassification.js";
91
92
  /**
92
93
  * NL-002: Classify MCP error messages into categories for AI disambiguation.
@@ -388,6 +389,9 @@ export class NeuroLink {
388
389
  // RequestRouter: pluggable pre-call provider/model selector.
389
390
  // Stored directly from config.requestRouter; null when not configured.
390
391
  requestRouter;
392
+ // ClassifierRouter: opt-in "classify → pick model + tools" pre-call router.
393
+ // Built from config.classifierRouter; null when not configured.
394
+ classifierRouter;
391
395
  /**
392
396
  * Merge instance-level credentials with per-call credentials.
393
397
  *
@@ -796,6 +800,17 @@ export class NeuroLink {
796
800
  this.modelPool = config?.modelPool ? new ModelPool(config.modelPool) : null;
797
801
  // RequestRouter: store the host-supplied function; null when not configured.
798
802
  this.requestRouter = config?.requestRouter ?? null;
803
+ // ClassifierRouter: opt-in. The LLM strategy reuses this instance's
804
+ // generate() (marked so it never recursively re-routes). Fails open.
805
+ this.classifierRouter = config?.classifierRouter?.enabled
806
+ ? new ClassifierRouter(config.classifierRouter, {
807
+ generate: (genOptions) => this.generate(genOptions),
808
+ logger: {
809
+ debug: (message, meta) => logger.debug(message, meta),
810
+ warn: (message, meta) => logger.warn(message, meta),
811
+ },
812
+ })
813
+ : null;
799
814
  logger.setEventEmitter(this.emitter);
800
815
  // Read tool cache duration from environment variables, with a default
801
816
  const cacheDurationEnv = process.env.NEUROLINK_TOOL_CACHE_DURATION;
@@ -3105,6 +3120,13 @@ Current user's request: ${currentInput}`;
3105
3120
  }
3106
3121
  async runStandardGenerateRequest(options, originalPrompt, generateSpan) {
3107
3122
  const startTime = Date.now();
3123
+ // Pre-call classifier router: opt-in. Classifies the request and selects a
3124
+ // provider/model from the configured base pool (and optionally narrows the
3125
+ // tool set) BEFORE orchestration/requestRouter, so it takes precedence.
3126
+ // Fails open.
3127
+ await this.applyClassifierRouting(options, options.input?.text ?? originalPrompt ?? "", !options.disableTools &&
3128
+ (!!(options.tools && Object.keys(options.tools).length > 0) ||
3129
+ this.getCustomTools().size > 0), !!(options.input?.images && options.input.images.length > 0), options.thinkingConfig?.thinkingLevel);
3108
3130
  await this.maybeApplyGenerateOrchestration(options);
3109
3131
  // Pre-call request router: opt-in, only when the caller did not set both
3110
3132
  // provider+model. Fails open (any router error leaves options unchanged).
@@ -3241,6 +3263,12 @@ Current user's request: ${currentInput}`;
3241
3263
  if (!this.requestRouter) {
3242
3264
  return;
3243
3265
  }
3266
+ // Stand down if the classifier router already selected a provider/model on
3267
+ // this turn — one explicit precedence order: classifierRouter wins.
3268
+ if (options.context
3269
+ ?.__classifierRouted) {
3270
+ return;
3271
+ }
3244
3272
  // Skip if the caller explicitly set both provider AND model — they know
3245
3273
  // what they want; the router should not override an intentional choice.
3246
3274
  if (options.provider && options.model) {
@@ -3307,6 +3335,103 @@ Current user's request: ${currentInput}`;
3307
3335
  });
3308
3336
  }
3309
3337
  }
3338
+ /**
3339
+ * Applies the host-configured `classifierRouter` to `options` in place.
3340
+ *
3341
+ * Classifies the request by difficulty and selects a provider/model from the
3342
+ * configured base pool (cheaper/faster for easy tasks, more capable for hard
3343
+ * ones), and optionally narrows the tool set via `toolFilter`/`excludeTools`.
3344
+ *
3345
+ * Skipped when: no router is configured; the inbound call is the classifier's
3346
+ * own generate() (marked `__classifierRouted`); the caller pinned BOTH
3347
+ * provider and model; or a ModelPool is configured (the pool owns selection).
3348
+ * Fails open — any error leaves options unchanged.
3349
+ */
3350
+ async applyClassifierRouting(options, promptText, hasTools, requiresVision, thinkingLevel) {
3351
+ if (!this.classifierRouter) {
3352
+ return;
3353
+ }
3354
+ const opt = options;
3355
+ // Prevent recursion: the LLM classifier itself calls generate() with this
3356
+ // marker set. Also respect any explicit prior routing decision.
3357
+ if (opt.context?.__classifierRouted) {
3358
+ return;
3359
+ }
3360
+ // Honor an intentional caller choice of BOTH provider and model.
3361
+ if (options.provider && options.model) {
3362
+ return;
3363
+ }
3364
+ // A ModelPool overrides provider/model per member unconditionally; let it
3365
+ // own selection (mirrors applyRequestRouter precedence).
3366
+ if (this.modelPool) {
3367
+ logger.debug("[NeuroLink] applyClassifierRouting: skipped — modelPool takes precedence");
3368
+ return;
3369
+ }
3370
+ const sessionId = typeof opt.context?.sessionId === "string"
3371
+ ? opt.context.sessionId
3372
+ : undefined;
3373
+ try {
3374
+ const decision = await this.classifierRouter.route({
3375
+ prompt: promptText,
3376
+ estimatedInputTokens: Math.ceil((promptText?.length ?? 0) / 4),
3377
+ hasTools,
3378
+ requiresVision,
3379
+ thinkingLevel,
3380
+ sessionId,
3381
+ });
3382
+ if (!decision) {
3383
+ return;
3384
+ }
3385
+ // Apply provider/model/region as a coherent set (same discipline as
3386
+ // applyRequestRouter): only fill fields the caller did not pin.
3387
+ if (decision.provider && !options.provider) {
3388
+ options.provider = decision.provider;
3389
+ if (decision.model && !options.model) {
3390
+ options.model = decision.model;
3391
+ }
3392
+ if (decision.region && !options.region) {
3393
+ options.region = decision.region;
3394
+ }
3395
+ }
3396
+ else if (!decision.provider && decision.model && !options.model) {
3397
+ options.model = decision.model;
3398
+ }
3399
+ // Narrow tools unless the caller disabled tools entirely.
3400
+ if (!opt.disableTools) {
3401
+ if (decision.excludeTools && decision.excludeTools.length > 0) {
3402
+ opt.excludeTools = Array.from(new Set([...(opt.excludeTools ?? []), ...decision.excludeTools]));
3403
+ }
3404
+ if (decision.toolFilter && decision.toolFilter.length > 0) {
3405
+ const allow = decision.toolFilter;
3406
+ opt.toolFilter =
3407
+ opt.toolFilter && opt.toolFilter.length > 0
3408
+ ? opt.toolFilter.filter((t) => allow.includes(t))
3409
+ : [...allow];
3410
+ }
3411
+ }
3412
+ // Mark routed so orchestration/requestRouter stand down this turn.
3413
+ if (decision.provider || decision.model) {
3414
+ const ctx = opt.context ?? {};
3415
+ ctx.__classifierRouted = true;
3416
+ opt.context = ctx;
3417
+ }
3418
+ if (decision.reason) {
3419
+ logger.debug("[NeuroLink] classifierRouter decision", {
3420
+ reason: decision.reason,
3421
+ difficulty: decision.difficulty,
3422
+ provider: options.provider,
3423
+ model: options.model,
3424
+ });
3425
+ }
3426
+ }
3427
+ catch (classifierErr) {
3428
+ logger.warn("[NeuroLink] classifierRouter threw — proceeding unrouted", {
3429
+ error: classifierErr instanceof Error
3430
+ ? classifierErr.message
3431
+ : String(classifierErr),
3432
+ });
3433
+ }
3434
+ }
3310
3435
  async prepareGenerateAugmentations(options) {
3311
3436
  if (options.rag?.files?.length) {
3312
3437
  try {
@@ -5829,6 +5954,12 @@ Current user's request: ${currentInput}`;
5829
5954
  // classify correctly. After the workflow short-circuit, so workflow
5830
5955
  // streams skip it.
5831
5956
  await context.with(streamSpanContext, () => this.setLangfuseContextFromOptions(options, () => this.applyToolRoutingExclusions(options, originalPrompt)));
5957
+ // Pre-call classifier router for stream: opt-in, fails open. Runs before
5958
+ // the request router so it takes precedence.
5959
+ await this.applyClassifierRouting(options, originalPrompt, !options.disableTools &&
5960
+ (!!(options.tools && Object.keys(options.tools).length > 0) ||
5961
+ this.getCustomTools().size > 0), !!(options.input?.images && options.input.images.length > 0), options
5962
+ .thinking?.thinkingLevel);
5832
5963
  // Pre-call request router for stream: opt-in, fails open.
5833
5964
  await this.applyRequestRouter(options, originalPrompt, !options.disableTools &&
5834
5965
  (!!(options.tools && Object.keys(options.tools).length > 0) ||
@@ -1,8 +1,10 @@
1
1
  import type { ZodType } from "zod";
2
2
  import { AIProviderName } from "../constants/enums.js";
3
3
  import { BaseProvider } from "../core/baseProvider.js";
4
- import type { EnhancedGenerateResult, TextGenerationOptions, StreamOptions, StreamResult } from "../types/index.js";
4
+ import type { EnhancedGenerateResult, TextGenerationOptions, StreamOptions, StreamResult, VertexAnthropicMessage, ChatMessage, MinimalChatMessage } from "../types/index.js";
5
5
  import type { Schema, LanguageModel } from "../types/index.js";
6
+ export declare function buildAnthropicHistoryMessages(conversationMessages: Array<ChatMessage | MinimalChatMessage>): VertexAnthropicMessage[];
7
+ export declare function appendUserMessage(messages: VertexAnthropicMessage[], content: VertexAnthropicMessage["content"]): void;
6
8
  /**
7
9
  * Recursively strip JSON-schema fields that Vertex Gemini's function-call AND
8
10
  * `responseSchema` (structured output) validators reject with 400
@@ -44,6 +44,144 @@ const hasAnthropicSupport = () => {
44
44
  // Actual availability is checked at runtime when creating the client
45
45
  return true;
46
46
  };
47
+ // Parse stored rows into ordered segments, grouping tool_call/tool_result by (turn, step).
48
+ function collectHistorySegments(conversationMessages) {
49
+ const segments = [];
50
+ const stepMap = new Map();
51
+ let turnCounter = 0;
52
+ const getStep = (stepIndex) => {
53
+ const key = `${turnCounter}:${stepIndex ?? "x"}`;
54
+ let step = stepMap.get(key);
55
+ if (!step) {
56
+ step = { type: "tool_step", callParts: [], resultParts: [] };
57
+ stepMap.set(key, step);
58
+ segments.push(step);
59
+ }
60
+ return step;
61
+ };
62
+ for (const msg of conversationMessages) {
63
+ if (msg.role === "tool_call") {
64
+ getStep(msg.metadata?.stepIndex).callParts.push({
65
+ name: msg.tool || "unknown",
66
+ input: msg.args || {},
67
+ });
68
+ continue;
69
+ }
70
+ if (msg.role === "tool_result") {
71
+ getStep(msg.metadata?.stepIndex).resultParts.push(msg.content && msg.content.length > 0 ? msg.content : "(no output)");
72
+ continue;
73
+ }
74
+ const role = msg.role === "assistant"
75
+ ? "assistant"
76
+ : msg.role === "user"
77
+ ? "user"
78
+ : null;
79
+ if (!role || !msg.content || msg.content.trim().length === 0) {
80
+ continue;
81
+ }
82
+ // New turn → fresh step namespace for any following tool rows.
83
+ turnCounter++;
84
+ segments.push({ type: "regular", role, parts: [msg.content] });
85
+ }
86
+ return segments;
87
+ }
88
+ // Append blocks to the trailing turn if it shares the role, else start a new turn.
89
+ function pushTurn(messages, role, blocks) {
90
+ const last = messages[messages.length - 1];
91
+ if (last && last.role === role && Array.isArray(last.content)) {
92
+ last.content.push(...blocks);
93
+ }
94
+ else {
95
+ messages.push({ role, content: [...blocks] });
96
+ }
97
+ }
98
+ // Pair a tool step's calls/results by position; unbalanced extras are dropped
99
+ // because Anthropic 400s on an orphaned tool_use/tool_result reference.
100
+ function pairToolStep(step, ordinal) {
101
+ const calls = step.callParts;
102
+ const resultStrings = step.resultParts;
103
+ const paired = Math.min(calls.length, resultStrings.length);
104
+ if (paired !== calls.length || paired !== resultStrings.length) {
105
+ logger.debug("[GoogleVertex] Unbalanced tool step in Claude history replay", {
106
+ calls: calls.length,
107
+ results: resultStrings.length,
108
+ paired,
109
+ });
110
+ }
111
+ const uses = [];
112
+ const results = [];
113
+ for (let i = 0; i < paired; i++) {
114
+ const id = `hist_${ordinal}_${i}`;
115
+ uses.push({
116
+ type: "tool_use",
117
+ id,
118
+ name: calls[i].name,
119
+ input: calls[i].input,
120
+ });
121
+ results.push({
122
+ type: "tool_result",
123
+ tool_use_id: id,
124
+ content: resultStrings[i],
125
+ });
126
+ }
127
+ return { uses, results };
128
+ }
129
+ // Drop leading turns until the first is a real user turn — Anthropic requires it
130
+ // (covers a leading assistant/tool step and an orphaned tool_result-only turn).
131
+ function trimLeadingNonUser(messages) {
132
+ const isToolResultOnly = (m) => Array.isArray(m.content) &&
133
+ m.content.length > 0 &&
134
+ m.content.every((block) => block.type === "tool_result");
135
+ while (messages.length > 0 &&
136
+ (messages[0].role !== "user" || isToolResultOnly(messages[0]))) {
137
+ messages.shift();
138
+ }
139
+ }
140
+ // Rebuild Anthropic history with paired tool_use/tool_result turns from stored rows.
141
+ export function buildAnthropicHistoryMessages(conversationMessages) {
142
+ const messages = [];
143
+ let stepOrdinal = 0;
144
+ for (const seg of collectHistorySegments(conversationMessages)) {
145
+ if (seg.type === "regular") {
146
+ pushTurn(messages, seg.role === "assistant" ? "assistant" : "user", [
147
+ { type: "text", text: String(seg.parts[0] ?? "") },
148
+ ]);
149
+ continue;
150
+ }
151
+ const { uses, results } = pairToolStep(seg, stepOrdinal);
152
+ if (uses.length === 0) {
153
+ continue;
154
+ }
155
+ stepOrdinal++;
156
+ pushTurn(messages, "assistant", uses);
157
+ pushTurn(messages, "user", results);
158
+ }
159
+ trimLeadingNonUser(messages);
160
+ if (logger.shouldLog("debug")) {
161
+ const blocks = messages.flatMap((m) => Array.isArray(m.content) ? m.content : []);
162
+ logger.debug("[GoogleVertex] Replayed Claude history with tool turns", {
163
+ historyMessages: messages.length,
164
+ toolUseBlocks: blocks.filter((b) => b.type === "tool_use").length,
165
+ toolResultBlocks: blocks.filter((b) => b.type === "tool_result").length,
166
+ });
167
+ }
168
+ return messages;
169
+ }
170
+ // Append the live user turn, merging into a trailing user turn to avoid back-to-back user messages.
171
+ export function appendUserMessage(messages, content) {
172
+ const last = messages[messages.length - 1];
173
+ if (last && last.role === "user") {
174
+ const head = Array.isArray(last.content)
175
+ ? last.content
176
+ : [{ type: "text", text: last.content }];
177
+ const tail = Array.isArray(content)
178
+ ? content
179
+ : [{ type: "text", text: content }];
180
+ last.content = [...head, ...tail];
181
+ return;
182
+ }
183
+ messages.push({ role: "user", content });
184
+ }
47
185
  /**
48
186
  * Recursively strip JSON-schema fields that Vertex Gemini's function-call AND
49
187
  * `responseSchema` (structured output) validators reject with 400
@@ -2287,18 +2425,14 @@ export class GoogleVertexProvider extends BaseProvider {
2287
2425
  });
2288
2426
  // Build messages from input
2289
2427
  const messages = [];
2290
- // Add conversation history if present.
2291
- //
2292
- // Intentionally text-only. Anthropic's API rejects messages where a
2293
- // tool_use_id reference appears without its matching tool_use in the
2294
- // same turn — so synthesising tool_use / tool_result blocks from
2295
- // stored ChatMessages risks emitting orphaned references that fail
2296
- // validation. Tool rows are still persisted to Redis (chat-history
2297
- // UI renders them) but they don't re-enter the model's context on
2298
- // subsequent turns.
2428
+ // Replay conversationMessages (with tool turns), else the legacy text-only conversationHistory.
2299
2429
  if (options.conversationMessages &&
2300
2430
  options.conversationMessages.length > 0) {
2301
- for (const msg of options.conversationMessages) {
2431
+ messages.push(...buildAnthropicHistoryMessages(options.conversationMessages));
2432
+ }
2433
+ else if (options.conversationHistory &&
2434
+ options.conversationHistory.length > 0) {
2435
+ for (const msg of options.conversationHistory) {
2302
2436
  if (msg.role === "user" || msg.role === "assistant") {
2303
2437
  messages.push({
2304
2438
  role: msg.role,
@@ -2430,13 +2564,10 @@ export class GoogleVertexProvider extends BaseProvider {
2430
2564
  type: "text",
2431
2565
  text: multimodalInput.text,
2432
2566
  });
2433
- // Add the user message with appropriate content format
2434
- messages.push({
2435
- role: "user",
2436
- content: userContentParts.length === 1 && userContentParts[0].type === "text"
2437
- ? multimodalInput.text
2438
- : userContentParts,
2439
- });
2567
+ // Append the live user turn (merges into a trailing user turn if present).
2568
+ appendUserMessage(messages, userContentParts.length === 1 && userContentParts[0].type === "text"
2569
+ ? multimodalInput.text
2570
+ : userContentParts);
2440
2571
  // Convert tools to Anthropic format if present
2441
2572
  let tools;
2442
2573
  const executeMap = new Map();
@@ -3056,20 +3187,14 @@ export class GoogleVertexProvider extends BaseProvider {
3056
3187
  // Build messages from input
3057
3188
  const messages = [];
3058
3189
  const inputText = options.prompt || options.input?.text || "Please respond.";
3059
- // Add conversation history if present. Prefer `conversationMessages`
3060
- // (what NeuroLink core injects today via MessageBuilder) and fall back
3061
- // to the legacy `conversationHistory` field for callers that still use
3062
- // the older surface. The Vertex Claude STREAM path already follows this
3063
- // priority — keeping the GENERATE path on `conversationHistory` only
3064
- // would silently drop multi-turn context for memory/loop sessions.
3065
- // Intentionally text-only: see the stream sibling for the rationale —
3066
- // synthesising tool_use / tool_result blocks from stored ChatMessages
3067
- // risks emitting orphaned references that Anthropic's API rejects.
3068
- const historyMessages = options.conversationMessages && options.conversationMessages.length > 0
3069
- ? options.conversationMessages
3070
- : options.conversationHistory;
3071
- if (historyMessages && historyMessages.length > 0) {
3072
- for (const msg of historyMessages) {
3190
+ // Replay conversationMessages (with tool turns), else the legacy text-only conversationHistory.
3191
+ if (options.conversationMessages &&
3192
+ options.conversationMessages.length > 0) {
3193
+ messages.push(...buildAnthropicHistoryMessages(options.conversationMessages));
3194
+ }
3195
+ else if (options.conversationHistory &&
3196
+ options.conversationHistory.length > 0) {
3197
+ for (const msg of options.conversationHistory) {
3073
3198
  if (msg.role === "user" || msg.role === "assistant") {
3074
3199
  messages.push({
3075
3200
  role: msg.role,
@@ -3201,13 +3326,10 @@ export class GoogleVertexProvider extends BaseProvider {
3201
3326
  type: "text",
3202
3327
  text: inputText,
3203
3328
  });
3204
- // Add the user message with appropriate content format
3205
- messages.push({
3206
- role: "user",
3207
- content: userContentParts.length === 1 && userContentParts[0].type === "text"
3208
- ? inputText
3209
- : userContentParts,
3210
- });
3329
+ // Append the live user turn (merges into a trailing user turn if present).
3330
+ appendUserMessage(messages, userContentParts.length === 1 && userContentParts[0].type === "text"
3331
+ ? inputText
3332
+ : userContentParts);
3211
3333
  // Convert tools to Anthropic format if present
3212
3334
  let tools;
3213
3335
  const executeMap = new Map();
@@ -0,0 +1,52 @@
1
+ /**
2
+ * ClassifierRouter — the single "classify → pick model + tools → run" engine.
3
+ *
4
+ * Given a request snapshot it (1) classifies difficulty (heuristic or a cheap
5
+ * LLM), (2) selects the best provider/model from the host-declared base pool —
6
+ * cheaper/faster for easy tiers, more capable for hard tiers — enriching pool
7
+ * members with real cost/quality metadata from the model registry, and
8
+ * (3) narrows the tool set via per-difficulty directives or classifier hints.
9
+ *
10
+ * Pure of provider imports (mirrors ModelPool): the LLM caller is injected.
11
+ * The only data dependency is the read-only `ModelResolver` registry lookup,
12
+ * used purely for metadata enrichment and never for provider construction.
13
+ */
14
+ import type { ClassifierRouterConfig, ClassifierRouterDecision, ClassifierRouterDeps, ClassifierRouterInput } from "../types/index.js";
15
+ export declare class ClassifierRouter {
16
+ private readonly config;
17
+ private readonly deps;
18
+ private readonly metaCache;
19
+ constructor(config: ClassifierRouterConfig, deps?: ClassifierRouterDeps);
20
+ /**
21
+ * Classify the request and produce a combined model + tool decision, or
22
+ * `null` when nothing should change. Never throws (fails open).
23
+ */
24
+ route(input: ClassifierRouterInput): Promise<ClassifierRouterDecision | null>;
25
+ /** Run the configured strategy; LLM falls back to heuristic on failure. */
26
+ private classify;
27
+ /**
28
+ * Build LLM-facing model descriptors + an id→member map so the classifier can
29
+ * pick a model directly. Works for ANY pool, registry-backed or not.
30
+ */
31
+ private buildCandidates;
32
+ /**
33
+ * Resolve the ranked model list for a decision. Prefers the LLM's direct pick
34
+ * (`selectedModelId`); otherwise falls back to difficulty-based tierMap /
35
+ * metadata scoring.
36
+ */
37
+ private selectModels;
38
+ /** Candidate members for a difficulty: explicit tierMap or eligible pool. */
39
+ private candidatesFor;
40
+ /** Drop members that cannot satisfy the required capabilities (lenient). */
41
+ private filterByCapabilities;
42
+ /** Rank candidates best-first for the difficulty's strategy. */
43
+ private rank;
44
+ /** Tool narrowing: per-difficulty directive, then classifier hints. */
45
+ private selectTools;
46
+ /**
47
+ * Resolve cost/quality/capabilities for a member. Declared values win;
48
+ * gaps are filled from the model registry (by model name/alias) when known.
49
+ * Results are cached per (provider/model) for the router's lifetime.
50
+ */
51
+ private metaFor;
52
+ }