@juspay/neurolink 12.18.1 → 12.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3 -3
- package/README.md +277 -75
- package/dist/browser/neurolink.min.js +396 -396
- package/dist/cli/commands/setup.js +2 -1
- package/dist/cli/factories/commandFactory.d.ts +22 -2
- package/dist/cli/factories/commandFactory.js +14 -2
- package/dist/cli/loop/optionsSchema.js +4 -0
- package/dist/cli/utils/classifierRouterFlags.js +39 -1
- package/dist/constants/enums.d.ts +13 -0
- package/dist/constants/enums.js +14 -0
- package/dist/context/budgetChecker.d.ts +3 -1
- package/dist/context/budgetChecker.js +20 -4
- package/dist/context/contextCompactor.d.ts +43 -3
- package/dist/context/contextCompactor.js +157 -41
- package/dist/context/contextDecision.d.ts +56 -0
- package/dist/context/contextDecision.js +234 -0
- package/dist/core/baseProvider.d.ts +27 -1
- package/dist/core/baseProvider.js +39 -0
- package/dist/core/toolRouting.d.ts +8 -0
- package/dist/core/toolRouting.js +52 -1
- package/dist/core/toolRoutingDecision.d.ts +35 -0
- package/dist/core/toolRoutingDecision.js +194 -0
- package/dist/factories/providerDescriptors.d.ts +15 -0
- package/dist/factories/providerDescriptors.js +57 -1
- package/dist/factories/providerRegistry.js +9 -1
- package/dist/index.d.ts +57 -1
- package/dist/index.js +60 -1
- package/dist/models/manifestRegistry.js +2 -0
- package/dist/models/manifests/typesafe.d.ts +16 -0
- package/dist/models/manifests/typesafe.js +42 -0
- package/dist/neurolink.d.ts +57 -1
- package/dist/neurolink.js +254 -10
- package/dist/observability/exporters/laminarExporter.js +3 -0
- package/dist/observability/exporters/posthogExporter.js +3 -0
- package/dist/observability/utils/spanSerializer.js +4 -0
- package/dist/providers/catalog/schema.d.ts +1 -1
- package/dist/providers/typesafe.d.ts +112 -0
- package/dist/providers/typesafe.js +654 -0
- package/dist/rag/pipeline/RAGPipeline.d.ts +3 -1
- package/dist/rag/pipeline/RAGPipeline.js +65 -4
- package/dist/rag/retrieval/searchDecision.d.ts +27 -0
- package/dist/rag/retrieval/searchDecision.js +124 -0
- package/dist/routing/classifierRouter.d.ts +74 -2
- package/dist/routing/classifierRouter.js +232 -32
- package/dist/routing/classifierStrategies.d.ts +33 -1
- package/dist/routing/classifierStrategies.js +237 -0
- package/dist/routing/index.d.ts +1 -1
- package/dist/routing/index.js +1 -1
- package/dist/routing/modelCatalog.d.ts +79 -0
- package/dist/routing/modelCatalog.js +382 -0
- package/dist/telemetry/tracers.d.ts +1 -0
- package/dist/telemetry/tracers.js +1 -0
- package/dist/types/classifierRouter.d.ts +160 -4
- package/dist/types/cli.d.ts +17 -1
- package/dist/types/config.d.ts +12 -0
- package/dist/types/context.d.ts +55 -1
- package/dist/types/decision.d.ts +200 -0
- package/dist/types/decision.js +21 -0
- package/dist/types/generate.d.ts +20 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/providers.d.ts +59 -1
- package/dist/types/providers.js +5 -0
- package/dist/types/rag.d.ts +48 -0
- package/dist/types/span.d.ts +11 -0
- package/dist/types/span.js +11 -0
- package/dist/types/stream.d.ts +13 -0
- package/dist/types/toolRouting.d.ts +56 -0
- package/dist/utils/decisionAnswers.d.ts +56 -0
- package/dist/utils/decisionAnswers.js +95 -0
- package/dist/utils/modelChoices.js +13 -1
- package/dist/utils/pricing.js +11 -0
- package/dist/utils/providerConfig.d.ts +4 -0
- package/dist/utils/providerConfig.js +17 -0
- package/docs-site/static/search-index.json +1 -1
- package/package.json +16 -3
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
* The only data dependency is the read-only `ModelResolver` registry lookup,
|
|
12
12
|
* used purely for metadata enrichment and never for provider construction.
|
|
13
13
|
*/
|
|
14
|
-
import { classifyHeuristic, classifyLlm } from "./classifierStrategies.js";
|
|
14
|
+
import { classifyHeuristic, classifyJev, classifyLlm, contextScopeToThreshold, CLASSIFIER_CONTEXT_SCOPES, } from "./classifierStrategies.js";
|
|
15
|
+
import { buildModelCatalog, buildRegistryIndex, enrichCandidate, rankCatalogue, } from "./modelCatalog.js";
|
|
15
16
|
import { ModelResolver } from "../models/modelResolver.js";
|
|
17
|
+
import { resolveDefaultDecisionProvider } from "../factories/providerDescriptors.js";
|
|
16
18
|
/** Maps a registry quality enum to a comparable numeric score. */
|
|
17
19
|
const QUALITY_SCORE = { high: 3, medium: 2, low: 1 };
|
|
18
20
|
/** How each difficulty ranks candidate members. */
|
|
@@ -34,9 +36,59 @@ export class ClassifierRouter {
|
|
|
34
36
|
config;
|
|
35
37
|
deps;
|
|
36
38
|
metaCache = new Map();
|
|
39
|
+
/**
|
|
40
|
+
* The effective pool: what the host declared, plus catalogue-derived members
|
|
41
|
+
* when `catalog.enabled`. Mutable so a long-lived host can widen or narrow
|
|
42
|
+
* it without rebuilding the router — the constructor-frozen pool was the one
|
|
43
|
+
* thing preventing a runtime model catalogue.
|
|
44
|
+
*/
|
|
45
|
+
pool;
|
|
46
|
+
/** Registry index for candidate enrichment; built once, on first use. */
|
|
47
|
+
registryIndex;
|
|
37
48
|
constructor(config, deps = {}) {
|
|
38
49
|
this.config = config;
|
|
39
50
|
this.deps = deps;
|
|
51
|
+
this.pool = ClassifierRouter.composePool(config);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Merge the declared pool with the catalogue. Declared members win on a
|
|
55
|
+
* duplicate `provider/model`, because a host that spelled a member out has
|
|
56
|
+
* said something about it (a region, a description, an explicit tier) that
|
|
57
|
+
* the registry does not know.
|
|
58
|
+
*/
|
|
59
|
+
static composePool(config) {
|
|
60
|
+
const declared = config.pool ?? [];
|
|
61
|
+
const catalogue = buildModelCatalog(config.catalog);
|
|
62
|
+
if (catalogue.length === 0) {
|
|
63
|
+
return [...declared];
|
|
64
|
+
}
|
|
65
|
+
const seen = new Set(declared.map((m) => m.id ?? `${m.provider}/${m.model ?? ""}`));
|
|
66
|
+
return [
|
|
67
|
+
...declared,
|
|
68
|
+
...catalogue.filter((m) => !seen.has(m.id ?? `${m.provider}/${m.model ?? ""}`)),
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Replace the routable pool at runtime. Returns the new size.
|
|
73
|
+
*
|
|
74
|
+
* Clears the metadata cache, since a member's declared cost/quality is
|
|
75
|
+
* cached per `provider::model` and a replacement pool may declare different
|
|
76
|
+
* values for the same pair.
|
|
77
|
+
*/
|
|
78
|
+
setPool(members) {
|
|
79
|
+
this.pool = [...members];
|
|
80
|
+
this.metaCache.clear();
|
|
81
|
+
return this.pool.length;
|
|
82
|
+
}
|
|
83
|
+
/** The pool currently routed over, declared plus catalogue. */
|
|
84
|
+
getPool() {
|
|
85
|
+
return [...this.pool];
|
|
86
|
+
}
|
|
87
|
+
/** Rebuild the catalogue half of the pool (e.g. after credentials change). */
|
|
88
|
+
refreshCatalog() {
|
|
89
|
+
this.pool = ClassifierRouter.composePool(this.config);
|
|
90
|
+
this.metaCache.clear();
|
|
91
|
+
return this.pool.length;
|
|
40
92
|
}
|
|
41
93
|
/**
|
|
42
94
|
* Classify the request and produce a combined model + tool decision, or
|
|
@@ -44,15 +96,21 @@ export class ClassifierRouter {
|
|
|
44
96
|
*/
|
|
45
97
|
async route(input) {
|
|
46
98
|
try {
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
const
|
|
99
|
+
// Both the LLM and Jev strategies can pick a model straight out of the
|
|
100
|
+
// pool — the generic path for custom/registry-less models.
|
|
101
|
+
const strategy = this.resolveStrategy();
|
|
102
|
+
const usesCandidates = (strategy === "jev" && !!this.deps.decide) ||
|
|
103
|
+
(strategy === "llm" && !!this.deps.generate);
|
|
104
|
+
const built = usesCandidates ? this.buildCandidates() : undefined;
|
|
105
|
+
const decision = await this.classify(strategy, input, built?.descriptors);
|
|
106
|
+
const ranked = this.selectModels(decision, input, built?.byId);
|
|
53
107
|
const primary = ranked[0];
|
|
54
108
|
const { toolFilter, excludeTools } = this.selectTools(decision);
|
|
55
|
-
|
|
109
|
+
const compactionThreshold = this.selectContextBudget(decision);
|
|
110
|
+
if (!primary &&
|
|
111
|
+
!toolFilter &&
|
|
112
|
+
!excludeTools &&
|
|
113
|
+
compactionThreshold === undefined) {
|
|
56
114
|
return null;
|
|
57
115
|
}
|
|
58
116
|
return {
|
|
@@ -63,6 +121,8 @@ export class ClassifierRouter {
|
|
|
63
121
|
modelFallbacks: ranked.slice(1),
|
|
64
122
|
toolFilter,
|
|
65
123
|
excludeTools,
|
|
124
|
+
compactionThreshold,
|
|
125
|
+
contextScope: decision.contextScope,
|
|
66
126
|
reason: decision.reason,
|
|
67
127
|
};
|
|
68
128
|
}
|
|
@@ -73,9 +133,38 @@ export class ClassifierRouter {
|
|
|
73
133
|
return null;
|
|
74
134
|
}
|
|
75
135
|
}
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Resolve "auto" (the default) to a concrete strategy. Jev is preferred the
|
|
138
|
+
* moment a TypeSafe key is present; without one the behaviour is exactly
|
|
139
|
+
* what it was before Jev existed.
|
|
140
|
+
*/
|
|
141
|
+
resolveStrategy() {
|
|
142
|
+
const configured = this.config.classifier ?? "auto";
|
|
143
|
+
if (configured !== "auto") {
|
|
144
|
+
return configured;
|
|
145
|
+
}
|
|
146
|
+
// A decision provider that is both registered AND has its key set is the
|
|
147
|
+
// whole activation condition — this is where "if somebody sets it we
|
|
148
|
+
// start using it" lives for routing.
|
|
149
|
+
return this.deps.decide && resolveDefaultDecisionProvider()
|
|
150
|
+
? "jev"
|
|
151
|
+
: "heuristic";
|
|
152
|
+
}
|
|
153
|
+
/** Run the chosen strategy; every one falls back to heuristic on failure. */
|
|
154
|
+
async classify(strategy, input, candidates) {
|
|
155
|
+
if (strategy === "jev" && this.deps.decide) {
|
|
156
|
+
try {
|
|
157
|
+
// classifyJev is already fail-open internally; this guards the rest.
|
|
158
|
+
return await classifyJev(input, this.deps.decide, this.config.timeoutMs, candidates, {
|
|
159
|
+
upgrade: this.config.minUpgradeConfidence,
|
|
160
|
+
downgrade: this.config.minDowngradeConfidence,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
this.deps.logger?.warn?.("[ClassifierRouter] Jev classify failed — using heuristic", { error: err instanceof Error ? err.message : String(err) });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (strategy === "llm" && this.deps.generate) {
|
|
79
168
|
try {
|
|
80
169
|
return await classifyLlm(input, this.deps.generate, this.config.classifierModel, this.config.timeoutMs, candidates);
|
|
81
170
|
}
|
|
@@ -90,25 +179,29 @@ export class ClassifierRouter {
|
|
|
90
179
|
* pick a model directly. Works for ANY pool, registry-backed or not.
|
|
91
180
|
*/
|
|
92
181
|
buildCandidates() {
|
|
93
|
-
|
|
182
|
+
// Candidates are built BEFORE classification, so they are scored against
|
|
183
|
+
// the neutral tier. The rendered description a model sees does not depend
|
|
184
|
+
// on difficulty anyway — it is a statement of the model's properties — and
|
|
185
|
+
// the deterministic fallback re-scores against the real tier once known.
|
|
186
|
+
this.registryIndex ??= buildRegistryIndex();
|
|
94
187
|
const byId = new Map();
|
|
95
188
|
const used = new Set();
|
|
96
189
|
const descriptors = [];
|
|
97
|
-
pool.forEach((m, i) => {
|
|
190
|
+
this.pool.forEach((m, i) => {
|
|
98
191
|
let id = m.id ?? (m.model ? `${m.provider}/${m.model}` : m.provider);
|
|
99
192
|
if (used.has(id)) {
|
|
100
193
|
id = `${id}#${i}`;
|
|
101
194
|
}
|
|
102
195
|
used.add(id);
|
|
103
196
|
byId.set(id, m);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
197
|
+
const candidate = enrichCandidate(id, m, "moderate", this.registryIndex);
|
|
198
|
+
// A host-declared capability list wins over the registry's, matching
|
|
199
|
+
// metaFor's precedence.
|
|
200
|
+
candidate.capabilities =
|
|
201
|
+
m.capabilities ??
|
|
202
|
+
candidate.capabilities ??
|
|
203
|
+
this.metaFor(m).capabilities;
|
|
204
|
+
descriptors.push(candidate);
|
|
112
205
|
});
|
|
113
206
|
return { descriptors, byId };
|
|
114
207
|
}
|
|
@@ -117,28 +210,114 @@ export class ClassifierRouter {
|
|
|
117
210
|
* (`selectedModelId`); otherwise falls back to difficulty-based tierMap /
|
|
118
211
|
* metadata scoring.
|
|
119
212
|
*/
|
|
120
|
-
selectModels(decision, byId) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
213
|
+
selectModels(decision, input, byId) {
|
|
214
|
+
const tierRanked = this.rankForDifficulty(decision, input);
|
|
215
|
+
const picked = decision.selectedModelId && byId
|
|
216
|
+
? byId.get(decision.selectedModelId)
|
|
217
|
+
: undefined;
|
|
218
|
+
if (picked) {
|
|
219
|
+
// A pick that cannot hold the request is vetoed outright, whatever the
|
|
220
|
+
// confidence. That failure is not a degraded answer but a hard provider
|
|
221
|
+
// error, which ModelPool records as a permanent cooldown.
|
|
222
|
+
if (!this.fitsRequest(picked, input)) {
|
|
223
|
+
this.deps.logger?.debug?.("[ClassifierRouter] classifier pick dropped — window too small", {
|
|
224
|
+
id: decision.selectedModelId,
|
|
225
|
+
estimatedInputTokens: input.estimatedInputTokens,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
else if (this.pickClearsBar(decision, picked, tierRanked[0])) {
|
|
229
|
+
return [picked, ...tierRanked.filter((m) => m !== picked)];
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
this.deps.logger?.debug?.("[ClassifierRouter] classifier pick dropped — below its bar", {
|
|
233
|
+
id: decision.selectedModelId,
|
|
234
|
+
confidence: decision.selectedModelConfidence,
|
|
235
|
+
});
|
|
127
236
|
}
|
|
128
237
|
}
|
|
238
|
+
return tierRanked;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Whether a directly-picked model clears the bar that applies to it.
|
|
242
|
+
*
|
|
243
|
+
* The bars are asymmetric because the two mistakes do not cost the same, and
|
|
244
|
+
* which one a pick risks depends on what the difficulty tier would otherwise
|
|
245
|
+
* have chosen. Picking something COSTLIER than the tier's own choice risks
|
|
246
|
+
* spending more than necessary, so it clears the low upgrade bar. Picking
|
|
247
|
+
* something CHEAPER risks handing the task to a model that cannot do it, so
|
|
248
|
+
* it must clear the high downgrade bar. Agreeing with the tier needs no bar
|
|
249
|
+
* at all.
|
|
250
|
+
*
|
|
251
|
+
* A strategy that reports no confidence for its pick — the LLM classifier —
|
|
252
|
+
* is honoured exactly as before. Its self-reported numbers are not
|
|
253
|
+
* calibrated, so a bar over them would be arithmetic on noise, and imposing
|
|
254
|
+
* one would silently change the behaviour of a shipped, unrelated strategy.
|
|
255
|
+
*/
|
|
256
|
+
pickClearsBar(decision, picked, tierTop) {
|
|
257
|
+
const confidence = decision.selectedModelConfidence;
|
|
258
|
+
if (confidence === undefined || !tierTop || picked === tierTop) {
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
const pickedCost = this.metaFor(picked).cost ?? NEUTRAL;
|
|
262
|
+
const topCost = this.metaFor(tierTop).cost ?? NEUTRAL;
|
|
263
|
+
const bar = pickedCost < topCost
|
|
264
|
+
? (this.config.minDowngradeConfidence ?? 0.6)
|
|
265
|
+
: (this.config.minUpgradeConfidence ?? 0.3);
|
|
266
|
+
return confidence >= bar;
|
|
267
|
+
}
|
|
268
|
+
/** The difficulty-based ranking, independent of any direct pick. */
|
|
269
|
+
rankForDifficulty(decision, input) {
|
|
129
270
|
const eligible = this.candidatesFor(decision);
|
|
130
|
-
const capable = this.filterByCapabilities(eligible, decision.requiredCapabilities);
|
|
271
|
+
const capable = this.withRoomFor(this.filterByCapabilities(eligible, decision.requiredCapabilities), input);
|
|
272
|
+
// With the catalogue on there is no hand-declared ordering to respect and
|
|
273
|
+
// far more members than a host would ever write out, so ranking uses the
|
|
274
|
+
// registry's use-case scores. Without it, the long-standing cost/quality
|
|
275
|
+
// comparator stands — a declared pool's behaviour is unchanged.
|
|
276
|
+
if (this.config.catalog?.enabled) {
|
|
277
|
+
this.registryIndex ??= buildRegistryIndex();
|
|
278
|
+
const index = this.registryIndex;
|
|
279
|
+
const enriched = capable.map((m, i) => enrichCandidate(m.id ?? (m.model ? `${m.provider}/${m.model}` : `${m.provider}#${i}`), m, decision.difficulty, index));
|
|
280
|
+
const order = rankCatalogue(enriched, decision.difficulty, input, decision.requiredCapabilities);
|
|
281
|
+
const byCandidateId = new Map(capable.map((m, i) => [
|
|
282
|
+
m.id ?? (m.model ? `${m.provider}/${m.model}` : `${m.provider}#${i}`),
|
|
283
|
+
m,
|
|
284
|
+
]));
|
|
285
|
+
const ranked = order
|
|
286
|
+
.map((c) => byCandidateId.get(c.id))
|
|
287
|
+
.filter((m) => m !== undefined);
|
|
288
|
+
if (ranked.length > 0) {
|
|
289
|
+
return ranked;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
131
292
|
return this.rank(capable, decision.difficulty);
|
|
132
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* Whether a member's context window can hold the estimated request. Unknown
|
|
296
|
+
* windows pass — the registry does not know every model, and starving the
|
|
297
|
+
* pool on missing metadata is worse than an occasional retry.
|
|
298
|
+
*/
|
|
299
|
+
fitsRequest(member, input) {
|
|
300
|
+
const needed = input.estimatedInputTokens ?? 0;
|
|
301
|
+
if (needed <= 0 || !member.model) {
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
this.registryIndex ??= buildRegistryIndex();
|
|
305
|
+
const info = this.registryIndex.get(member.model);
|
|
306
|
+
return !info || info.limits.maxContextTokens >= needed;
|
|
307
|
+
}
|
|
308
|
+
/** Drop members too small for the request, unless that empties the pool. */
|
|
309
|
+
withRoomFor(members, input) {
|
|
310
|
+
const kept = members.filter((m) => this.fitsRequest(m, input));
|
|
311
|
+
return kept.length > 0 ? kept : members;
|
|
312
|
+
}
|
|
133
313
|
/** Candidate members for a difficulty: explicit tierMap or eligible pool. */
|
|
134
314
|
candidatesFor(decision) {
|
|
135
315
|
const tierMembers = this.config.tierMap?.[decision.difficulty];
|
|
136
316
|
if (tierMembers && tierMembers.length > 0) {
|
|
137
317
|
return tierMembers;
|
|
138
318
|
}
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
return eligible.length > 0 ? eligible : pool;
|
|
319
|
+
const eligible = this.pool.filter((m) => !m.tiers || m.tiers.includes(decision.difficulty));
|
|
320
|
+
return eligible.length > 0 ? eligible : this.pool;
|
|
142
321
|
}
|
|
143
322
|
/** Drop members that cannot satisfy the required capabilities (lenient). */
|
|
144
323
|
filterByCapabilities(members, required) {
|
|
@@ -197,6 +376,27 @@ export class ClassifierRouter {
|
|
|
197
376
|
// documented weight contract that a plain append would silently drop.
|
|
198
377
|
return [...sortMeasured(measured), ...sortMeasured(unmeasured)];
|
|
199
378
|
}
|
|
379
|
+
/**
|
|
380
|
+
* Turn the classifier's context reading into a compaction threshold.
|
|
381
|
+
*
|
|
382
|
+
* Returns undefined — meaning "leave the 0.8 default alone" — whenever the
|
|
383
|
+
* feature is off, no scope was read, or the scope is the widest one. The
|
|
384
|
+
* mapping can only ever LOWER the threshold, which is enforced in
|
|
385
|
+
* `contextScopeToThreshold` rather than here, because the consequence of
|
|
386
|
+
* getting it wrong in the other direction is not recoverable: a
|
|
387
|
+
* `context_window` error puts the model into ModelPool's 10-year cooldown.
|
|
388
|
+
*/
|
|
389
|
+
selectContextBudget(decision) {
|
|
390
|
+
if (this.config.contextBudget === false || !decision.contextScope) {
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
const index = CLASSIFIER_CONTEXT_SCOPES.indexOf(decision.contextScope);
|
|
394
|
+
if (index < 0) {
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
const threshold = contextScopeToThreshold(index);
|
|
398
|
+
return threshold < 0.8 ? threshold : undefined;
|
|
399
|
+
}
|
|
200
400
|
/** Tool narrowing: per-difficulty directive, then classifier hints. */
|
|
201
401
|
selectTools(decision) {
|
|
202
402
|
const directive = this.config.toolDirectives?.[decision.difficulty];
|
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
* - `classifyLlm` — runs a cheap "classifier model" via an injected generate
|
|
7
7
|
* function, asking for a schema-constrained difficulty verdict. Falls back to
|
|
8
8
|
* the heuristic on any failure.
|
|
9
|
+
* - `classifyJev` — one round trip to a decision model, which answers
|
|
10
|
+
* difficulty, capabilities, risk and model choice *simultaneously* and
|
|
11
|
+
* reports a calibrated confidence. Falls back to the heuristic on any
|
|
12
|
+
* failure or when the verdict is not confident enough to act on.
|
|
9
13
|
*/
|
|
10
|
-
import type { ClassifierCandidate, ClassifierDecision, ClassifierDifficulty, ClassifierGenerateFn, ClassifierModelRef, ClassifierRouterInput } from "../types/index.js";
|
|
14
|
+
import type { ClassifierCandidate, ClassifierContextScope, ClassifierDecideFn, ClassifierDecision, ClassifierDifficulty, ClassifierGenerateFn, ClassifierModelRef, ClassifierRouterInput } from "../types/index.js";
|
|
11
15
|
/** Difficulty tiers, ordered easiest → hardest. */
|
|
12
16
|
export declare const CLASSIFIER_DIFFICULTIES: ClassifierDifficulty[];
|
|
13
17
|
/**
|
|
@@ -21,3 +25,31 @@ export declare function classifyHeuristic(input: ClassifierRouterInput): Classif
|
|
|
21
25
|
* returns output that does not match the schema.
|
|
22
26
|
*/
|
|
23
27
|
export declare function classifyLlm(input: ClassifierRouterInput, generate: ClassifierGenerateFn, classifierModel?: ClassifierModelRef, timeoutMs?: number, candidates?: ClassifierCandidate[]): Promise<ClassifierDecision>;
|
|
28
|
+
/**
|
|
29
|
+
* How much context the request needs, narrowest → widest. Ordered, because a
|
|
30
|
+
* decision model places things on a scale reliably and names numbers badly:
|
|
31
|
+
* asking "how many tokens" would get digits read as text, while asking "which
|
|
32
|
+
* of these four descriptions fits" gets a calibrated position.
|
|
33
|
+
*/
|
|
34
|
+
export declare const CLASSIFIER_CONTEXT_SCOPES: ClassifierContextScope[];
|
|
35
|
+
/**
|
|
36
|
+
* Map a score answer onto a compaction threshold, clamped so it can only ever
|
|
37
|
+
* lower the default.
|
|
38
|
+
*/
|
|
39
|
+
export declare function contextScopeToThreshold(index: number): number;
|
|
40
|
+
/**
|
|
41
|
+
* Decision-model classifier — one request answers difficulty, capabilities,
|
|
42
|
+
* risk and model selection at once.
|
|
43
|
+
*
|
|
44
|
+
* Latency is flat in question count (1 question ~393ms, 400 ~465ms), so the
|
|
45
|
+
* capability questions are effectively free and are asked speculatively even
|
|
46
|
+
* when nothing downstream may need them. This is the opposite of the LLM
|
|
47
|
+
* strategy's economics, where every extra field costs output tokens.
|
|
48
|
+
*
|
|
49
|
+
* Returns the heuristic verdict — never throws — when no decision provider is
|
|
50
|
+
* configured, the call fails, or the answer is not confident enough to act on.
|
|
51
|
+
*/
|
|
52
|
+
export declare function classifyJev(input: ClassifierRouterInput, decide: ClassifierDecideFn, timeoutMs?: number, candidates?: ClassifierCandidate[], thresholds?: {
|
|
53
|
+
upgrade?: number;
|
|
54
|
+
downgrade?: number;
|
|
55
|
+
}): Promise<ClassifierDecision>;
|
|
@@ -6,10 +6,16 @@
|
|
|
6
6
|
* - `classifyLlm` — runs a cheap "classifier model" via an injected generate
|
|
7
7
|
* function, asking for a schema-constrained difficulty verdict. Falls back to
|
|
8
8
|
* the heuristic on any failure.
|
|
9
|
+
* - `classifyJev` — one round trip to a decision model, which answers
|
|
10
|
+
* difficulty, capabilities, risk and model choice *simultaneously* and
|
|
11
|
+
* reports a calibrated confidence. Falls back to the heuristic on any
|
|
12
|
+
* failure or when the verdict is not confident enough to act on.
|
|
9
13
|
*/
|
|
10
14
|
import { z } from "zod";
|
|
11
15
|
import { analyzePrompt, calculateConfidence, } from "../utils/taskClassificationUtils.js";
|
|
12
16
|
import { withTimeout } from "../utils/async/index.js";
|
|
17
|
+
import { decisionBooleanConfidence, readDecisionBoolean, readDecisionChoice, readDecisionScore, } from "../utils/decisionAnswers.js";
|
|
18
|
+
import { renderCandidate } from "./modelCatalog.js";
|
|
13
19
|
/** Difficulty tiers, ordered easiest → hardest. */
|
|
14
20
|
export const CLASSIFIER_DIFFICULTIES = [
|
|
15
21
|
"trivial",
|
|
@@ -153,3 +159,234 @@ export async function classifyLlm(input, generate, classifierModel, timeoutMs, c
|
|
|
153
159
|
reason: d.reason ?? "llm classifier",
|
|
154
160
|
};
|
|
155
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Rubric handed to the decision model, ordered easiest → hardest. Deliberately
|
|
164
|
+
* about the shape of the work, not about model names: the model never sees a
|
|
165
|
+
* provider or model id, and this file maps its judgement onto the pool.
|
|
166
|
+
*/
|
|
167
|
+
const JEV_DIFFICULTY_CRITERIA = {
|
|
168
|
+
trivial: "Mechanical and local: rename a symbol, fix a typo, add an import, run one named command, or answer something already stated in the request.",
|
|
169
|
+
simple: "A small localised change or a direct factual answer. One file, one obvious approach.",
|
|
170
|
+
moderate: "Ordinary engineering: implement a well-specified change across a few files, write tests, fix a clearly described bug, review a small diff.",
|
|
171
|
+
hard: "Deep reasoning: architecture and design, debugging a failure whose cause is unknown, security analysis, concurrency, refactors spanning several systems.",
|
|
172
|
+
expert: "Frontier-level work: ambiguous requirements, novel design with no established pattern, or analysis where a wrong answer is costly and hard to detect.",
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* Asked about the ACT, not the subject.
|
|
176
|
+
*
|
|
177
|
+
* This wording matters more than it looks. The obvious phrasing — "the task
|
|
178
|
+
* touches production, money or credentials" — scores very high on ordinary
|
|
179
|
+
* code that merely *concerns* those things ("add a refund endpoint that calls
|
|
180
|
+
* Stripe"), and escalates every such request to the most expensive model. The
|
|
181
|
+
* second sentence is what separates writing the code from running it.
|
|
182
|
+
*/
|
|
183
|
+
const JEV_RISK_INSTRUCTIONS = "Carrying out this request would itself change production, move real money, expose credentials, or alter data that cannot be restored. Writing or testing code that deals with such things, without running it against the real system, does not count.";
|
|
184
|
+
/** Above this, risk overrides the tier and skips the confidence thresholds. */
|
|
185
|
+
const JEV_RISK_FORCE_THRESHOLD = 0.7;
|
|
186
|
+
/**
|
|
187
|
+
* How much context the request needs, narrowest → widest. Ordered, because a
|
|
188
|
+
* decision model places things on a scale reliably and names numbers badly:
|
|
189
|
+
* asking "how many tokens" would get digits read as text, while asking "which
|
|
190
|
+
* of these four descriptions fits" gets a calibrated position.
|
|
191
|
+
*/
|
|
192
|
+
export const CLASSIFIER_CONTEXT_SCOPES = [
|
|
193
|
+
"current-message",
|
|
194
|
+
"recent-turns",
|
|
195
|
+
"full-conversation",
|
|
196
|
+
"everything",
|
|
197
|
+
];
|
|
198
|
+
const JEV_CONTEXT_CRITERIA = [
|
|
199
|
+
"Self-contained. Everything needed to answer is in the request itself; earlier conversation would not change the answer.",
|
|
200
|
+
"Needs the last few exchanges — a follow-up, a correction, or a reference to something just discussed.",
|
|
201
|
+
"Needs the whole conversation, including decisions and constraints established much earlier.",
|
|
202
|
+
"Needs the conversation and every document, file and tool result that has been gathered.",
|
|
203
|
+
];
|
|
204
|
+
/**
|
|
205
|
+
* Compaction thresholds per context scope, indexed to match
|
|
206
|
+
* {@link CLASSIFIER_CONTEXT_SCOPES}.
|
|
207
|
+
*
|
|
208
|
+
* Every one is at or below the 0.8 default, and that is a hard invariant
|
|
209
|
+
* rather than a tuning choice. Raising a budget lets a request through that
|
|
210
|
+
* the model then rejects for exceeding its window — and `ModelPool` treats a
|
|
211
|
+
* `context_window` error as a PERMANENT cooldown (10 years), so one
|
|
212
|
+
* optimistic guess retires that model for the life of the process. Shrinking
|
|
213
|
+
* a budget merely compacts a little earlier than it had to.
|
|
214
|
+
*/
|
|
215
|
+
const CONTEXT_SCOPE_THRESHOLD = [0.45, 0.6, 0.75, 0.8];
|
|
216
|
+
/** Below this calibrated confidence the scope reading is ignored entirely. */
|
|
217
|
+
const MIN_CONTEXT_SCOPE_CONFIDENCE = 0.5;
|
|
218
|
+
/** The default the budget checker uses when nobody passes a threshold. */
|
|
219
|
+
const DEFAULT_COMPACTION_THRESHOLD = 0.8;
|
|
220
|
+
/**
|
|
221
|
+
* Map a score answer onto a compaction threshold, clamped so it can only ever
|
|
222
|
+
* lower the default.
|
|
223
|
+
*/
|
|
224
|
+
export function contextScopeToThreshold(index) {
|
|
225
|
+
const clamped = Math.max(0, Math.min(CONTEXT_SCOPE_THRESHOLD.length - 1, Math.round(index)));
|
|
226
|
+
return Math.min(DEFAULT_COMPACTION_THRESHOLD, CONTEXT_SCOPE_THRESHOLD[clamped] ?? DEFAULT_COMPACTION_THRESHOLD);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Default confidence bars. They differ because the two mistakes do not cost
|
|
230
|
+
* the same: routing a simple task to an expensive model wastes money, while
|
|
231
|
+
* routing a hard task to a weak one produces a wrong answer.
|
|
232
|
+
*/
|
|
233
|
+
const DEFAULT_MIN_UPGRADE_CONFIDENCE = 0.3;
|
|
234
|
+
const DEFAULT_MIN_DOWNGRADE_CONFIDENCE = 0.6;
|
|
235
|
+
/** The neutral tier a verdict is judged as moving up or down from. */
|
|
236
|
+
const NEUTRAL_DIFFICULTY_INDEX = CLASSIFIER_DIFFICULTIES.indexOf("moderate");
|
|
237
|
+
/** Narrow an arbitrary option name back onto the difficulty union. */
|
|
238
|
+
function isClassifierDifficulty(value) {
|
|
239
|
+
return CLASSIFIER_DIFFICULTIES.includes(value);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Decision-model classifier — one request answers difficulty, capabilities,
|
|
243
|
+
* risk and model selection at once.
|
|
244
|
+
*
|
|
245
|
+
* Latency is flat in question count (1 question ~393ms, 400 ~465ms), so the
|
|
246
|
+
* capability questions are effectively free and are asked speculatively even
|
|
247
|
+
* when nothing downstream may need them. This is the opposite of the LLM
|
|
248
|
+
* strategy's economics, where every extra field costs output tokens.
|
|
249
|
+
*
|
|
250
|
+
* Returns the heuristic verdict — never throws — when no decision provider is
|
|
251
|
+
* configured, the call fails, or the answer is not confident enough to act on.
|
|
252
|
+
*/
|
|
253
|
+
export async function classifyJev(input, decide, timeoutMs, candidates, thresholds) {
|
|
254
|
+
const minUpgrade = thresholds?.upgrade ?? DEFAULT_MIN_UPGRADE_CONFIDENCE;
|
|
255
|
+
const minDowngrade = thresholds?.downgrade ?? DEFAULT_MIN_DOWNGRADE_CONFIDENCE;
|
|
256
|
+
const questions = {
|
|
257
|
+
difficulty: {
|
|
258
|
+
type: "choice",
|
|
259
|
+
instructions: "What kind of work does this request require from an AI coding assistant? Judge by reasoning depth, number of steps, domain expertise, and ambiguity.",
|
|
260
|
+
criteria: JEV_DIFFICULTY_CRITERIA,
|
|
261
|
+
},
|
|
262
|
+
needs_vision: {
|
|
263
|
+
type: "boolean",
|
|
264
|
+
instructions: "Does answering this request require looking at an image, screenshot, diagram, or PDF page?",
|
|
265
|
+
},
|
|
266
|
+
needs_tools: {
|
|
267
|
+
type: "boolean",
|
|
268
|
+
instructions: "Does this request require running commands, reading or writing files, or calling external services — as opposed to being answerable from the text alone?",
|
|
269
|
+
},
|
|
270
|
+
needs_reasoning: {
|
|
271
|
+
type: "boolean",
|
|
272
|
+
instructions: "Does this request require sustained multi-step reasoning before a correct answer can be given?",
|
|
273
|
+
},
|
|
274
|
+
risky: { type: "boolean", instructions: JEV_RISK_INSTRUCTIONS },
|
|
275
|
+
context: {
|
|
276
|
+
type: "score",
|
|
277
|
+
instructions: "How much of the earlier conversation does answering this request actually require?",
|
|
278
|
+
criteria: JEV_CONTEXT_CRITERIA,
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
// Only ask the model-selection question when there is a pool to choose
|
|
282
|
+
// from; a choice over fewer than two options carries no information.
|
|
283
|
+
const byId = new Map((candidates ?? []).map((c) => [c.id, c]));
|
|
284
|
+
if (byId.size > 1) {
|
|
285
|
+
questions.model = {
|
|
286
|
+
type: "choice",
|
|
287
|
+
instructions: "Which of these models is the cheapest one that can still complete this request correctly? A model whose context window cannot hold the request is never the right answer.",
|
|
288
|
+
criteria: Object.fromEntries(Array.from(byId.values()).map((c) => [
|
|
289
|
+
c.id,
|
|
290
|
+
renderCandidate(c) || null,
|
|
291
|
+
])),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
// Structured state: the decision API accepts JSON, and the extra signals
|
|
295
|
+
// are already assembled by the caller. Sending them costs almost nothing
|
|
296
|
+
// and they are exactly what distinguishes a long tool-using request from a
|
|
297
|
+
// short conversational one.
|
|
298
|
+
const result = await decide({
|
|
299
|
+
state: {
|
|
300
|
+
request: (input.prompt ?? "").slice(0, 8000),
|
|
301
|
+
estimated_input_tokens: input.estimatedInputTokens ?? 0,
|
|
302
|
+
has_tools_available: input.hasTools ?? false,
|
|
303
|
+
request_includes_images: input.requiresVision ?? false,
|
|
304
|
+
caller_requested_thinking: input.thinkingLevel ?? "none",
|
|
305
|
+
},
|
|
306
|
+
questions,
|
|
307
|
+
timeoutMs,
|
|
308
|
+
});
|
|
309
|
+
if (!result) {
|
|
310
|
+
return classifyHeuristic(input);
|
|
311
|
+
}
|
|
312
|
+
// Read first, and keep it separate from everything below. The context
|
|
313
|
+
// judgement is independent of the tier — a trivial request can still depend
|
|
314
|
+
// on the whole conversation, and an expert one can be entirely
|
|
315
|
+
// self-contained — so it is gated on its own calibrated confidence and
|
|
316
|
+
// survives every path on which the difficulty verdict is discarded.
|
|
317
|
+
const contextReading = readDecisionScore(result.answers, "context");
|
|
318
|
+
const contextScope = contextReading && contextReading.confidence >= MIN_CONTEXT_SCOPE_CONFIDENCE
|
|
319
|
+
? CLASSIFIER_CONTEXT_SCOPES[Math.max(0, Math.min(CLASSIFIER_CONTEXT_SCOPES.length - 1, Math.round(contextReading.score)))]
|
|
320
|
+
: undefined;
|
|
321
|
+
const withContext = (decision) => contextScope
|
|
322
|
+
? {
|
|
323
|
+
...decision,
|
|
324
|
+
contextScope,
|
|
325
|
+
contextScopeConfidence: contextReading?.confidence,
|
|
326
|
+
}
|
|
327
|
+
: decision;
|
|
328
|
+
const difficulty = readDecisionChoice(result.answers, "difficulty");
|
|
329
|
+
if (!difficulty || !isClassifierDifficulty(difficulty.choice)) {
|
|
330
|
+
return withContext(classifyHeuristic(input));
|
|
331
|
+
}
|
|
332
|
+
// Risk overrides the tier outright and skips the confidence bars — this is
|
|
333
|
+
// the one case that is not a confidence question. It may only ever RAISE
|
|
334
|
+
// the tier, never lower one.
|
|
335
|
+
const risk = readDecisionBoolean(result.answers, "risky") ?? 0;
|
|
336
|
+
const forcedByRisk = risk > JEV_RISK_FORCE_THRESHOLD;
|
|
337
|
+
const wantedIndex = CLASSIFIER_DIFFICULTIES.indexOf(difficulty.choice);
|
|
338
|
+
const effectiveIndex = forcedByRisk
|
|
339
|
+
? Math.max(wantedIndex, CLASSIFIER_DIFFICULTIES.indexOf("hard"))
|
|
340
|
+
: wantedIndex;
|
|
341
|
+
// Asymmetric gate: a verdict harder than neutral spends more, so it clears
|
|
342
|
+
// the low bar; one easier than neutral spends less, so it clears the high
|
|
343
|
+
// bar. Below the applicable bar the heuristic stands.
|
|
344
|
+
if (!forcedByRisk) {
|
|
345
|
+
const isDowngrade = effectiveIndex < NEUTRAL_DIFFICULTY_INDEX;
|
|
346
|
+
const bar = isDowngrade ? minDowngrade : minUpgrade;
|
|
347
|
+
if (difficulty.confidence < bar) {
|
|
348
|
+
const fallback = classifyHeuristic(input);
|
|
349
|
+
return withContext({
|
|
350
|
+
...fallback,
|
|
351
|
+
reason: `jev: ${difficulty.choice} @ ${difficulty.confidence.toFixed(2)} below the ${isDowngrade ? "downgrade" : "upgrade"} bar ${bar} — ${fallback.reason}`,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const capabilities = new Set();
|
|
356
|
+
// A yes/no answer carries no confidence of its own, so require it to be
|
|
357
|
+
// clearly on one side of the coin flip before acting on it.
|
|
358
|
+
const capabilityFor = {
|
|
359
|
+
needs_vision: "vision",
|
|
360
|
+
needs_tools: "tools",
|
|
361
|
+
needs_reasoning: "reasoning",
|
|
362
|
+
};
|
|
363
|
+
for (const [id, tag] of Object.entries(capabilityFor)) {
|
|
364
|
+
const probability = readDecisionBoolean(result.answers, id);
|
|
365
|
+
if (probability !== undefined &&
|
|
366
|
+
probability > 0.5 &&
|
|
367
|
+
decisionBooleanConfidence(probability) >= 0.4) {
|
|
368
|
+
capabilities.add(tag);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// The pick is reported WITHOUT a confidence gate here on purpose. Whether a
|
|
372
|
+
// given pick is an upgrade or a downgrade depends on what the difficulty
|
|
373
|
+
// tier would otherwise have chosen, and only the router knows that — so the
|
|
374
|
+
// asymmetric bars are applied there. Gating on the upgrade bar at this point
|
|
375
|
+
// treated every pick as an upgrade, which let a low-confidence pick route a
|
|
376
|
+
// hard task DOWN to a cheap model on a bar meant for the opposite case.
|
|
377
|
+
const picked = readDecisionChoice(result.answers, "model");
|
|
378
|
+
const selectedModelId = picked && byId.has(picked.choice) ? picked.choice : undefined;
|
|
379
|
+
const chosen = CLASSIFIER_DIFFICULTIES[effectiveIndex];
|
|
380
|
+
return {
|
|
381
|
+
difficulty: chosen,
|
|
382
|
+
confidence: difficulty.confidence,
|
|
383
|
+
requiredCapabilities: withRequestCapabilities(input, Array.from(capabilities)),
|
|
384
|
+
selectedModelId,
|
|
385
|
+
selectedModelConfidence: selectedModelId ? picked?.confidence : undefined,
|
|
386
|
+
contextScope,
|
|
387
|
+
contextScopeConfidence: contextReading?.confidence,
|
|
388
|
+
reason: forcedByRisk
|
|
389
|
+
? `jev(${result.model}): ${chosen}, forced by risk ${risk.toFixed(2)} in ${result.latencyMs}ms`
|
|
390
|
+
: `jev(${result.model}): ${chosen} @ ${difficulty.confidence.toFixed(2)} in ${result.latencyMs}ms`,
|
|
391
|
+
};
|
|
392
|
+
}
|
package/dist/routing/index.d.ts
CHANGED
|
@@ -6,4 +6,4 @@
|
|
|
6
6
|
export { classifyProviderError, ModelPool } from "./modelPool.js";
|
|
7
7
|
export { createDefaultRequestRouter } from "./requestRouter.js";
|
|
8
8
|
export { ClassifierRouter } from "./classifierRouter.js";
|
|
9
|
-
export { classifyHeuristic, classifyLlm } from "./classifierStrategies.js";
|
|
9
|
+
export { classifyHeuristic, classifyJev, classifyLlm, } from "./classifierStrategies.js";
|
package/dist/routing/index.js
CHANGED
|
@@ -6,4 +6,4 @@
|
|
|
6
6
|
export { classifyProviderError, ModelPool } from "./modelPool.js";
|
|
7
7
|
export { createDefaultRequestRouter } from "./requestRouter.js";
|
|
8
8
|
export { ClassifierRouter } from "./classifierRouter.js";
|
|
9
|
-
export { classifyHeuristic, classifyLlm } from "./classifierStrategies.js";
|
|
9
|
+
export { classifyHeuristic, classifyJev, classifyLlm, } from "./classifierStrategies.js";
|