@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.
- package/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +349 -348
- package/dist/cli/factories/commandFactory.js +61 -0
- package/dist/cli/utils/classifierRouterFlags.d.ts +19 -0
- package/dist/cli/utils/classifierRouterFlags.js +111 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/index.d.ts +1 -1
- package/dist/lib/index.js +1 -1
- package/dist/lib/neurolink.d.ts +14 -0
- package/dist/lib/neurolink.js +131 -0
- package/dist/lib/providers/googleVertex.d.ts +3 -1
- package/dist/lib/providers/googleVertex.js +160 -38
- package/dist/lib/routing/classifierRouter.d.ts +52 -0
- package/dist/lib/routing/classifierRouter.js +269 -0
- package/dist/lib/routing/classifierStrategies.d.ts +23 -0
- package/dist/lib/routing/classifierStrategies.js +156 -0
- package/dist/lib/routing/index.d.ts +2 -0
- package/dist/lib/routing/index.js +2 -0
- package/dist/lib/session/globalSessionState.d.ts +10 -1
- package/dist/lib/session/globalSessionState.js +18 -0
- package/dist/lib/types/classifierRouter.d.ts +193 -0
- package/dist/lib/types/classifierRouter.js +17 -0
- package/dist/lib/types/cli.d.ts +27 -3
- package/dist/lib/types/config.d.ts +10 -0
- package/dist/lib/types/index.d.ts +1 -0
- package/dist/lib/types/index.js +2 -0
- package/dist/neurolink.d.ts +14 -0
- package/dist/neurolink.js +131 -0
- package/dist/providers/googleVertex.d.ts +3 -1
- package/dist/providers/googleVertex.js +160 -38
- package/dist/routing/classifierRouter.d.ts +52 -0
- package/dist/routing/classifierRouter.js +268 -0
- package/dist/routing/classifierStrategies.d.ts +23 -0
- package/dist/routing/classifierStrategies.js +155 -0
- package/dist/routing/index.d.ts +2 -0
- package/dist/routing/index.js +2 -0
- package/dist/session/globalSessionState.d.ts +10 -1
- package/dist/session/globalSessionState.js +18 -0
- package/dist/types/classifierRouter.d.ts +193 -0
- package/dist/types/classifierRouter.js +16 -0
- package/dist/types/cli.d.ts +27 -3
- package/dist/types/config.d.ts +10 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +2 -0
- package/package.json +2 -1
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
2434
|
-
messages.
|
|
2435
|
-
|
|
2436
|
-
|
|
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
|
-
//
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
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
|
-
//
|
|
3205
|
-
messages.
|
|
3206
|
-
|
|
3207
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
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 { classifyHeuristic, classifyLlm } from "./classifierStrategies.js";
|
|
15
|
+
import { ModelResolver } from "../models/modelResolver.js";
|
|
16
|
+
/** Maps a registry quality enum to a comparable numeric score. */
|
|
17
|
+
const QUALITY_SCORE = { high: 3, medium: 2, low: 1 };
|
|
18
|
+
/** How each difficulty ranks candidate members. */
|
|
19
|
+
const DIFFICULTY_RANK_MODE = {
|
|
20
|
+
trivial: "cost-asc",
|
|
21
|
+
simple: "cost-asc",
|
|
22
|
+
moderate: "balanced",
|
|
23
|
+
hard: "quality-desc",
|
|
24
|
+
expert: "quality-desc",
|
|
25
|
+
};
|
|
26
|
+
/** Neutral default for an unknown numeric metric (keeps ordering stable). */
|
|
27
|
+
const NEUTRAL = 0.5;
|
|
28
|
+
/**
|
|
29
|
+
* Cap on the per-(provider,model) metadata cache so it can't grow unbounded in
|
|
30
|
+
* long-lived processes with large or dynamic pools. FIFO eviction.
|
|
31
|
+
*/
|
|
32
|
+
const MAX_META_CACHE_ENTRIES = 1000;
|
|
33
|
+
export class ClassifierRouter {
|
|
34
|
+
config;
|
|
35
|
+
deps;
|
|
36
|
+
metaCache = new Map();
|
|
37
|
+
constructor(config, deps = {}) {
|
|
38
|
+
this.config = config;
|
|
39
|
+
this.deps = deps;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Classify the request and produce a combined model + tool decision, or
|
|
43
|
+
* `null` when nothing should change. Never throws (fails open).
|
|
44
|
+
*/
|
|
45
|
+
async route(input) {
|
|
46
|
+
try {
|
|
47
|
+
// For the LLM strategy, hand the pool to the classifier so it can select
|
|
48
|
+
// a model directly — the generic path for custom/registry-less models.
|
|
49
|
+
const useLlm = this.config.classifier === "llm" && !!this.deps.generate;
|
|
50
|
+
const built = useLlm ? this.buildCandidates() : undefined;
|
|
51
|
+
const decision = await this.classify(input, built?.descriptors);
|
|
52
|
+
const ranked = this.selectModels(decision, built?.byId);
|
|
53
|
+
const primary = ranked[0];
|
|
54
|
+
const { toolFilter, excludeTools } = this.selectTools(decision);
|
|
55
|
+
if (!primary && !toolFilter && !excludeTools) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
provider: primary?.provider,
|
|
60
|
+
model: primary?.model,
|
|
61
|
+
region: primary?.region,
|
|
62
|
+
difficulty: decision.difficulty,
|
|
63
|
+
modelFallbacks: ranked.slice(1),
|
|
64
|
+
toolFilter,
|
|
65
|
+
excludeTools,
|
|
66
|
+
reason: decision.reason,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
this.deps.logger?.warn?.("[ClassifierRouter] route failed — no-op", {
|
|
71
|
+
error: err instanceof Error ? err.message : String(err),
|
|
72
|
+
});
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Run the configured strategy; LLM falls back to heuristic on failure. */
|
|
77
|
+
async classify(input, candidates) {
|
|
78
|
+
if (this.config.classifier === "llm" && this.deps.generate) {
|
|
79
|
+
try {
|
|
80
|
+
return await classifyLlm(input, this.deps.generate, this.config.classifierModel, this.config.timeoutMs, candidates);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
this.deps.logger?.warn?.("[ClassifierRouter] LLM classify failed — using heuristic", { error: err instanceof Error ? err.message : String(err) });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return classifyHeuristic(input);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Build LLM-facing model descriptors + an id→member map so the classifier can
|
|
90
|
+
* pick a model directly. Works for ANY pool, registry-backed or not.
|
|
91
|
+
*/
|
|
92
|
+
buildCandidates() {
|
|
93
|
+
const pool = this.config.pool ?? [];
|
|
94
|
+
const byId = new Map();
|
|
95
|
+
const used = new Set();
|
|
96
|
+
const descriptors = [];
|
|
97
|
+
pool.forEach((m, i) => {
|
|
98
|
+
let id = m.id ?? (m.model ? `${m.provider}/${m.model}` : m.provider);
|
|
99
|
+
if (used.has(id)) {
|
|
100
|
+
id = `${id}#${i}`;
|
|
101
|
+
}
|
|
102
|
+
used.add(id);
|
|
103
|
+
byId.set(id, m);
|
|
104
|
+
descriptors.push({
|
|
105
|
+
id,
|
|
106
|
+
provider: m.provider,
|
|
107
|
+
model: m.model,
|
|
108
|
+
description: m.description,
|
|
109
|
+
tiers: m.tiers,
|
|
110
|
+
capabilities: this.metaFor(m).capabilities,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
return { descriptors, byId };
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the ranked model list for a decision. Prefers the LLM's direct pick
|
|
117
|
+
* (`selectedModelId`); otherwise falls back to difficulty-based tierMap /
|
|
118
|
+
* metadata scoring.
|
|
119
|
+
*/
|
|
120
|
+
selectModels(decision, byId) {
|
|
121
|
+
if (decision.selectedModelId && byId) {
|
|
122
|
+
const picked = byId.get(decision.selectedModelId);
|
|
123
|
+
if (picked) {
|
|
124
|
+
const rest = (this.config.pool ?? []).filter((m) => m !== picked);
|
|
125
|
+
const rankedRest = this.rank(this.filterByCapabilities(rest, decision.requiredCapabilities), decision.difficulty);
|
|
126
|
+
return [picked, ...rankedRest];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const eligible = this.candidatesFor(decision);
|
|
130
|
+
const capable = this.filterByCapabilities(eligible, decision.requiredCapabilities);
|
|
131
|
+
return this.rank(capable, decision.difficulty);
|
|
132
|
+
}
|
|
133
|
+
/** Candidate members for a difficulty: explicit tierMap or eligible pool. */
|
|
134
|
+
candidatesFor(decision) {
|
|
135
|
+
const tierMembers = this.config.tierMap?.[decision.difficulty];
|
|
136
|
+
if (tierMembers && tierMembers.length > 0) {
|
|
137
|
+
return tierMembers;
|
|
138
|
+
}
|
|
139
|
+
const pool = this.config.pool ?? [];
|
|
140
|
+
const eligible = pool.filter((m) => !m.tiers || m.tiers.includes(decision.difficulty));
|
|
141
|
+
return eligible.length > 0 ? eligible : pool;
|
|
142
|
+
}
|
|
143
|
+
/** Drop members that cannot satisfy the required capabilities (lenient). */
|
|
144
|
+
filterByCapabilities(members, required) {
|
|
145
|
+
if (!required || required.length === 0) {
|
|
146
|
+
return members;
|
|
147
|
+
}
|
|
148
|
+
const kept = members.filter((m) => {
|
|
149
|
+
const caps = this.metaFor(m).capabilities;
|
|
150
|
+
// Unknown capabilities → keep (don't starve the pool on missing metadata).
|
|
151
|
+
if (!caps || caps.length === 0) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
return required.every((c) => caps.includes(c));
|
|
155
|
+
});
|
|
156
|
+
return kept.length > 0 ? kept : members;
|
|
157
|
+
}
|
|
158
|
+
/** Rank candidates best-first for the difficulty's strategy. */
|
|
159
|
+
rank(members, difficulty) {
|
|
160
|
+
const mode = DIFFICULTY_RANK_MODE[difficulty];
|
|
161
|
+
const originalIndex = new Map(members.map((m, i) => [m, i]));
|
|
162
|
+
const num = (v) => (typeof v === "number" ? v : NEUTRAL);
|
|
163
|
+
return [...members].sort((a, b) => {
|
|
164
|
+
const ma = this.metaFor(a);
|
|
165
|
+
const mb = this.metaFor(b);
|
|
166
|
+
let delta;
|
|
167
|
+
if (mode === "cost-asc") {
|
|
168
|
+
delta = num(ma.cost) - num(mb.cost);
|
|
169
|
+
}
|
|
170
|
+
else if (mode === "quality-desc") {
|
|
171
|
+
delta = num(mb.quality) - num(ma.quality);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
// balanced: maximize quality-minus-cost
|
|
175
|
+
delta =
|
|
176
|
+
num(mb.quality) - num(mb.cost) - (num(ma.quality) - num(ma.cost));
|
|
177
|
+
}
|
|
178
|
+
if (delta !== 0) {
|
|
179
|
+
return delta;
|
|
180
|
+
}
|
|
181
|
+
const weightDelta = (b.weight ?? 1) - (a.weight ?? 1);
|
|
182
|
+
if (weightDelta !== 0) {
|
|
183
|
+
return weightDelta;
|
|
184
|
+
}
|
|
185
|
+
// Stable: preserve declared pool order on a tie.
|
|
186
|
+
return (originalIndex.get(a) ?? 0) - (originalIndex.get(b) ?? 0);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
/** Tool narrowing: per-difficulty directive, then classifier hints. */
|
|
190
|
+
selectTools(decision) {
|
|
191
|
+
const directive = this.config.toolDirectives?.[decision.difficulty];
|
|
192
|
+
let toolFilter = directive?.toolFilter
|
|
193
|
+
? [...directive.toolFilter]
|
|
194
|
+
: undefined;
|
|
195
|
+
const excludeTools = directive?.excludeTools
|
|
196
|
+
? [...directive.excludeTools]
|
|
197
|
+
: undefined;
|
|
198
|
+
// When no explicit allowlist is configured, honor the classifier's hint.
|
|
199
|
+
if ((!toolFilter || toolFilter.length === 0) &&
|
|
200
|
+
decision.suggestedTools &&
|
|
201
|
+
decision.suggestedTools.length > 0) {
|
|
202
|
+
toolFilter = [...decision.suggestedTools];
|
|
203
|
+
}
|
|
204
|
+
return { toolFilter, excludeTools };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Resolve cost/quality/capabilities for a member. Declared values win;
|
|
208
|
+
* gaps are filled from the model registry (by model name/alias) when known.
|
|
209
|
+
* Results are cached per (provider/model) for the router's lifetime.
|
|
210
|
+
*/
|
|
211
|
+
metaFor(member) {
|
|
212
|
+
const key = `${member.provider}::${member.model ?? ""}`;
|
|
213
|
+
const cached = this.metaCache.get(key);
|
|
214
|
+
if (cached) {
|
|
215
|
+
return cached;
|
|
216
|
+
}
|
|
217
|
+
let cost = member.cost;
|
|
218
|
+
let quality = member.quality;
|
|
219
|
+
let capabilities = member.capabilities
|
|
220
|
+
? [...member.capabilities]
|
|
221
|
+
: undefined;
|
|
222
|
+
const needsEnrichment = cost === undefined || quality === undefined || capabilities === undefined;
|
|
223
|
+
if (needsEnrichment && member.model) {
|
|
224
|
+
try {
|
|
225
|
+
const info = ModelResolver.resolveModel(member.model);
|
|
226
|
+
if (info) {
|
|
227
|
+
if (cost === undefined) {
|
|
228
|
+
cost = info.pricing.inputCostPer1K + info.pricing.outputCostPer1K;
|
|
229
|
+
}
|
|
230
|
+
if (quality === undefined) {
|
|
231
|
+
quality = QUALITY_SCORE[info.performance.quality] ?? NEUTRAL;
|
|
232
|
+
}
|
|
233
|
+
if (capabilities === undefined) {
|
|
234
|
+
const caps = [];
|
|
235
|
+
if (info.capabilities.vision) {
|
|
236
|
+
caps.push("vision");
|
|
237
|
+
}
|
|
238
|
+
if (info.capabilities.functionCalling) {
|
|
239
|
+
caps.push("tools");
|
|
240
|
+
}
|
|
241
|
+
if (info.capabilities.reasoning) {
|
|
242
|
+
caps.push("reasoning");
|
|
243
|
+
}
|
|
244
|
+
if (info.capabilities.codeGeneration) {
|
|
245
|
+
caps.push("code");
|
|
246
|
+
}
|
|
247
|
+
if (info.capabilities.multimodal) {
|
|
248
|
+
caps.push("multimodal");
|
|
249
|
+
}
|
|
250
|
+
capabilities = caps;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
// Enrichment is best-effort; ignore registry lookup failures.
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const meta = { cost, quality, capabilities };
|
|
259
|
+
if (this.metaCache.size >= MAX_META_CACHE_ENTRIES) {
|
|
260
|
+
const oldest = this.metaCache.keys().next().value;
|
|
261
|
+
if (oldest !== undefined) {
|
|
262
|
+
this.metaCache.delete(oldest);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
this.metaCache.set(key, meta);
|
|
266
|
+
return meta;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
//# sourceMappingURL=classifierRouter.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classification strategies for the ClassifierRouter.
|
|
3
|
+
*
|
|
4
|
+
* - `classifyHeuristic` — zero-cost, no LLM. Generalizes the existing binary
|
|
5
|
+
* task classifier scorer (fast vs reasoning) into five difficulty tiers.
|
|
6
|
+
* - `classifyLlm` — runs a cheap "classifier model" via an injected generate
|
|
7
|
+
* function, asking for a schema-constrained difficulty verdict. Falls back to
|
|
8
|
+
* the heuristic on any failure.
|
|
9
|
+
*/
|
|
10
|
+
import type { ClassifierCandidate, ClassifierDecision, ClassifierDifficulty, ClassifierGenerateFn, ClassifierModelRef, ClassifierRouterInput } from "../types/index.js";
|
|
11
|
+
/** Difficulty tiers, ordered easiest → hardest. */
|
|
12
|
+
export declare const CLASSIFIER_DIFFICULTIES: ClassifierDifficulty[];
|
|
13
|
+
/**
|
|
14
|
+
* Heuristic classifier — maps the binary fast/reasoning scores plus prompt
|
|
15
|
+
* length into one of five difficulty tiers. Deterministic and dependency-free.
|
|
16
|
+
*/
|
|
17
|
+
export declare function classifyHeuristic(input: ClassifierRouterInput): ClassifierDecision;
|
|
18
|
+
/**
|
|
19
|
+
* LLM classifier — asks a cheap model for a schema-constrained verdict.
|
|
20
|
+
* Falls back to the heuristic if the model is unavailable, times out, or
|
|
21
|
+
* returns output that does not match the schema.
|
|
22
|
+
*/
|
|
23
|
+
export declare function classifyLlm(input: ClassifierRouterInput, generate: ClassifierGenerateFn, classifierModel?: ClassifierModelRef, timeoutMs?: number, candidates?: ClassifierCandidate[]): Promise<ClassifierDecision>;
|