@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
486 lines (485 loc) • 22.5 kB
JavaScript
/**
* ClassifierRouter — the single "classify → pick model + tools → run" engine.
*
* Given a request snapshot it (1) classifies difficulty (heuristic or a cheap
* LLM), (2) selects the best provider/model from the host-declared base pool —
* cheaper/faster for easy tiers, more capable for hard tiers — enriching pool
* members with real cost/quality metadata from the model registry, and
* (3) narrows the tool set via per-difficulty directives or classifier hints.
*
* Pure of provider imports (mirrors ModelPool): the LLM caller is injected.
* The only data dependency is the read-only `ModelResolver` registry lookup,
* used purely for metadata enrichment and never for provider construction.
*/
import { classifyHeuristic, classifyJev, classifyLlm, contextScopeToThreshold, CLASSIFIER_CONTEXT_SCOPES, } from "./classifierStrategies.js";
import { buildModelCatalog, buildRegistryIndex, enrichCandidate, rankCatalogue, } from "./modelCatalog.js";
import { ModelResolver } from "../models/modelResolver.js";
import { resolveDefaultDecisionProvider } from "../factories/providerDescriptors.js";
/** Maps a registry quality enum to a comparable numeric score. */
const QUALITY_SCORE = { high: 3, medium: 2, low: 1 };
/** How each difficulty ranks candidate members. */
const DIFFICULTY_RANK_MODE = {
trivial: "cost-asc",
simple: "cost-asc",
moderate: "balanced",
hard: "quality-desc",
expert: "quality-desc",
};
/** Neutral default for an unknown numeric metric (keeps ordering stable). */
const NEUTRAL = 0.5;
/**
* Cap on the per-(provider,model) metadata cache so it can't grow unbounded in
* long-lived processes with large or dynamic pools. FIFO eviction.
*/
const MAX_META_CACHE_ENTRIES = 1000;
export class ClassifierRouter {
config;
deps;
metaCache = new Map();
/**
* The effective pool: what the host declared, plus catalogue-derived members
* when `catalog.enabled`. Mutable so a long-lived host can widen or narrow
* it without rebuilding the router — the constructor-frozen pool was the one
* thing preventing a runtime model catalogue.
*/
pool;
/** Registry index for candidate enrichment; built once, on first use. */
registryIndex;
constructor(config, deps = {}) {
this.config = config;
this.deps = deps;
this.pool = ClassifierRouter.composePool(config);
}
/**
* Merge the declared pool with the catalogue. Declared members win on a
* duplicate `provider/model`, because a host that spelled a member out has
* said something about it (a region, a description, an explicit tier) that
* the registry does not know.
*/
static composePool(config) {
const declared = config.pool ?? [];
const catalogue = buildModelCatalog(config.catalog);
if (catalogue.length === 0) {
return [...declared];
}
const seen = new Set(declared.map((m) => m.id ?? `${m.provider}/${m.model ?? ""}`));
return [
...declared,
...catalogue.filter((m) => !seen.has(m.id ?? `${m.provider}/${m.model ?? ""}`)),
];
}
/**
* Replace the routable pool at runtime. Returns the new size.
*
* Clears the metadata cache, since a member's declared cost/quality is
* cached per `provider::model` and a replacement pool may declare different
* values for the same pair.
*/
setPool(members) {
this.pool = [...members];
this.metaCache.clear();
return this.pool.length;
}
/** The pool currently routed over, declared plus catalogue. */
getPool() {
return [...this.pool];
}
/** Rebuild the catalogue half of the pool (e.g. after credentials change). */
refreshCatalog() {
this.pool = ClassifierRouter.composePool(this.config);
this.metaCache.clear();
return this.pool.length;
}
/**
* Classify the request and produce a combined model + tool decision, or
* `null` when nothing should change. Never throws (fails open).
*/
async route(input) {
try {
// Both the LLM and Jev strategies can pick a model straight out of the
// pool — the generic path for custom/registry-less models.
const strategy = this.resolveStrategy(input);
const usesCandidates = (strategy === "jev" && !!this.deps.decide) ||
(strategy === "llm" && !!this.deps.generate);
const built = usesCandidates ? this.buildCandidates() : undefined;
const decision = await this.classify(strategy, input, built?.descriptors);
const ranked = this.selectModels(decision, input, built?.byId);
const primary = ranked[0];
const { toolFilter, excludeTools } = this.selectTools(decision);
const compactionThreshold = this.selectContextBudget(decision);
if (!primary &&
!toolFilter &&
!excludeTools &&
compactionThreshold === undefined) {
return null;
}
return {
provider: primary?.provider,
model: primary?.model,
region: primary?.region,
difficulty: decision.difficulty,
modelFallbacks: ranked.slice(1),
toolFilter,
excludeTools,
compactionThreshold,
contextScope: decision.contextScope,
reason: decision.reason,
};
}
catch (err) {
this.deps.logger?.warn?.("[ClassifierRouter] route failed — no-op", {
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}
/**
* Resolve "auto" (the default) to a concrete strategy. Jev is preferred the
* moment a decision provider is configured; without one the behaviour is
* exactly what it was before Jev existed.
*/
resolveStrategy(input) {
const configured = this.config.classifier ?? "auto";
if (configured !== "auto") {
return configured;
}
// A decision provider that is both registered AND configured is the whole
// activation condition — this is where "if somebody sets it we start
// using it" lives for routing. The caller's check also counts the
// credentials it was constructed with and the request's own per-call
// credentials, which the decision call itself will use.
const hasProvider = this.deps.hasDecisionProvider
? this.deps.hasDecisionProvider(input.credentials)
: resolveDefaultDecisionProvider(input.credentials) !== undefined;
return this.deps.decide && hasProvider ? "jev" : "heuristic";
}
/** Run the chosen strategy; every one falls back to heuristic on failure. */
async classify(strategy, input, candidates) {
if (strategy === "jev" && this.deps.decide) {
try {
// classifyJev is already fail-open internally; this guards the rest.
return await classifyJev(input, this.deps.decide, this.config.timeoutMs, candidates, {
upgrade: this.config.minUpgradeConfidence,
downgrade: this.config.minDowngradeConfidence,
});
}
catch (err) {
this.deps.logger?.warn?.("[ClassifierRouter] Jev classify failed — using heuristic", { error: err instanceof Error ? err.message : String(err) });
}
}
if (strategy === "llm" && this.deps.generate) {
try {
return await classifyLlm(input, this.deps.generate, this.config.classifierModel, this.config.timeoutMs, candidates);
}
catch (err) {
this.deps.logger?.warn?.("[ClassifierRouter] LLM classify failed — using heuristic", { error: err instanceof Error ? err.message : String(err) });
}
}
return classifyHeuristic(input);
}
/**
* Build LLM-facing model descriptors + an id→member map so the classifier can
* pick a model directly. Works for ANY pool, registry-backed or not.
*/
buildCandidates() {
// Candidates are built BEFORE classification, so they are scored against
// the neutral tier. The rendered description a model sees does not depend
// on difficulty anyway — it is a statement of the model's properties — and
// the deterministic fallback re-scores against the real tier once known.
this.registryIndex ??= buildRegistryIndex();
const byId = new Map();
const used = new Set();
const descriptors = [];
this.pool.forEach((m, i) => {
let id = m.id ?? (m.model ? `${m.provider}/${m.model}` : m.provider);
if (used.has(id)) {
id = `${id}#${i}`;
}
used.add(id);
byId.set(id, m);
const candidate = enrichCandidate(id, m, "moderate", this.registryIndex);
// A host-declared capability list wins over the registry's, matching
// metaFor's precedence.
candidate.capabilities =
m.capabilities ??
candidate.capabilities ??
this.metaFor(m).capabilities;
descriptors.push(candidate);
});
return { descriptors, byId };
}
/**
* Resolve the ranked model list for a decision. Prefers the LLM's direct pick
* (`selectedModelId`); otherwise falls back to difficulty-based tierMap /
* metadata scoring.
*/
selectModels(decision, input, byId) {
const tierRanked = this.rankForDifficulty(decision, input);
const picked = decision.selectedModelId && byId
? byId.get(decision.selectedModelId)
: undefined;
if (picked) {
// A pick that cannot hold the request is vetoed outright, whatever the
// confidence. That failure is not a degraded answer but a hard provider
// error, which ModelPool records as a permanent cooldown.
if (!this.fitsRequest(picked, input)) {
this.deps.logger?.debug?.("[ClassifierRouter] classifier pick dropped — window too small", {
id: decision.selectedModelId,
estimatedInputTokens: input.estimatedInputTokens,
});
}
else if (this.pickClearsBar(decision, picked, tierRanked[0])) {
return [picked, ...tierRanked.filter((m) => m !== picked)];
}
else {
this.deps.logger?.debug?.("[ClassifierRouter] classifier pick dropped — below its bar", {
id: decision.selectedModelId,
confidence: decision.selectedModelConfidence,
});
}
}
return tierRanked;
}
/**
* Whether a directly-picked model clears the bar that applies to it.
*
* The bars are asymmetric because the two mistakes do not cost the same, and
* which one a pick risks depends on what the difficulty tier would otherwise
* have chosen. Picking something COSTLIER than the tier's own choice risks
* spending more than necessary, so it clears the low upgrade bar. Picking
* something CHEAPER risks handing the task to a model that cannot do it, so
* it must clear the high downgrade bar. Agreeing with the tier needs no bar
* at all.
*
* A strategy that reports no confidence for its pick — the LLM classifier —
* is honoured exactly as before. Its self-reported numbers are not
* calibrated, so a bar over them would be arithmetic on noise, and imposing
* one would silently change the behaviour of a shipped, unrelated strategy.
*/
pickClearsBar(decision, picked, tierTop) {
const confidence = decision.selectedModelConfidence;
if (confidence === undefined || !tierTop || picked === tierTop) {
return true;
}
const pickedCost = this.metaFor(picked).cost ?? NEUTRAL;
const topCost = this.metaFor(tierTop).cost ?? NEUTRAL;
const bar = pickedCost < topCost
? (this.config.minDowngradeConfidence ?? 0.6)
: (this.config.minUpgradeConfidence ?? 0.3);
return confidence >= bar;
}
/** The difficulty-based ranking, independent of any direct pick. */
rankForDifficulty(decision, input) {
const eligible = this.candidatesFor(decision);
const capable = this.withRoomFor(this.filterByCapabilities(eligible, decision.requiredCapabilities), input);
// With the catalogue on there is no hand-declared ordering to respect and
// far more members than a host would ever write out, so ranking uses the
// registry's use-case scores. Without it, the long-standing cost/quality
// comparator stands — a declared pool's behaviour is unchanged.
if (this.config.catalog?.enabled) {
this.registryIndex ??= buildRegistryIndex();
const index = this.registryIndex;
const enriched = capable.map((m, i) => enrichCandidate(m.id ?? (m.model ? `${m.provider}/${m.model}` : `${m.provider}#${i}`), m, decision.difficulty, index));
const order = rankCatalogue(enriched, decision.difficulty, input, decision.requiredCapabilities);
const byCandidateId = new Map(capable.map((m, i) => [
m.id ?? (m.model ? `${m.provider}/${m.model}` : `${m.provider}#${i}`),
m,
]));
const ranked = order
.map((c) => byCandidateId.get(c.id))
.filter((m) => m !== undefined);
if (ranked.length > 0) {
return ranked;
}
}
return this.rank(capable, decision.difficulty);
}
/**
* Whether a member's context window can hold the estimated request. Unknown
* windows pass — the registry does not know every model, and starving the
* pool on missing metadata is worse than an occasional retry.
*/
fitsRequest(member, input) {
const needed = input.estimatedInputTokens ?? 0;
if (needed <= 0 || !member.model) {
return true;
}
this.registryIndex ??= buildRegistryIndex();
const info = this.registryIndex.get(member.model);
return !info || info.limits.maxContextTokens >= needed;
}
/** Drop members too small for the request, unless that empties the pool. */
withRoomFor(members, input) {
const kept = members.filter((m) => this.fitsRequest(m, input));
return kept.length > 0 ? kept : members;
}
/** Candidate members for a difficulty: explicit tierMap or eligible pool. */
candidatesFor(decision) {
const tierMembers = this.config.tierMap?.[decision.difficulty];
if (tierMembers && tierMembers.length > 0) {
return tierMembers;
}
const eligible = this.pool.filter((m) => !m.tiers || m.tiers.includes(decision.difficulty));
return eligible.length > 0 ? eligible : this.pool;
}
/** Drop members that cannot satisfy the required capabilities (lenient). */
filterByCapabilities(members, required) {
if (!required || required.length === 0) {
return members;
}
const kept = members.filter((m) => {
const caps = this.metaFor(m).capabilities;
// Unknown capabilities → keep (don't starve the pool on missing metadata).
if (!caps || caps.length === 0) {
return true;
}
return required.every((c) => caps.includes(c));
});
return kept.length > 0 ? kept : members;
}
/** Rank candidates best-first for the difficulty's strategy. */
rank(members, difficulty) {
const mode = DIFFICULTY_RANK_MODE[difficulty];
const originalIndex = new Map(members.map((m, i) => [m, i]));
const num = (v) => (typeof v === "number" ? v : NEUTRAL);
const isFullyUnmeasured = (m) => {
const meta = this.metaFor(m);
return meta.cost === undefined && meta.quality === undefined;
};
const measured = members.filter((m) => !isFullyUnmeasured(m));
const unmeasured = members.filter(isFullyUnmeasured);
const sortMeasured = (pool) => [...pool].sort((a, b) => {
const ma = this.metaFor(a);
const mb = this.metaFor(b);
let delta;
if (mode === "cost-asc") {
delta = num(ma.cost) - num(mb.cost);
}
else if (mode === "quality-desc") {
delta = num(mb.quality) - num(ma.quality);
}
else {
// balanced: maximize quality-minus-cost
delta =
num(mb.quality) - num(mb.cost) - (num(ma.quality) - num(ma.cost));
}
if (delta !== 0) {
return delta;
}
const weightDelta = (b.weight ?? 1) - (a.weight ?? 1);
if (weightDelta !== 0) {
return weightDelta;
}
// Stable: preserve declared pool order on a tie.
return (originalIndex.get(a) ?? 0) - (originalIndex.get(b) ?? 0);
});
// The unmeasured bucket ranks after every measured member, but WITHIN the
// bucket the same comparator still applies: with cost/quality both
// NEUTRAL it falls through to the weight tie-break, preserving the
// documented weight contract that a plain append would silently drop.
return [...sortMeasured(measured), ...sortMeasured(unmeasured)];
}
/**
* Turn the classifier's context reading into a compaction threshold.
*
* Returns undefined — meaning "leave the 0.8 default alone" — whenever the
* feature is off, no scope was read, or the scope is the widest one. The
* mapping can only ever LOWER the threshold, which is enforced in
* `contextScopeToThreshold` rather than here, because the consequence of
* getting it wrong in the other direction is not recoverable: a
* `context_window` error puts the model into ModelPool's 10-year cooldown.
*/
selectContextBudget(decision) {
if (this.config.contextBudget === false || !decision.contextScope) {
return undefined;
}
const index = CLASSIFIER_CONTEXT_SCOPES.indexOf(decision.contextScope);
if (index < 0) {
return undefined;
}
const threshold = contextScopeToThreshold(index);
return threshold < 0.8 ? threshold : undefined;
}
/** Tool narrowing: per-difficulty directive, then classifier hints. */
selectTools(decision) {
const directive = this.config.toolDirectives?.[decision.difficulty];
let toolFilter = directive?.toolFilter
? [...directive.toolFilter]
: undefined;
const excludeTools = directive?.excludeTools
? [...directive.excludeTools]
: undefined;
// When no explicit allowlist is configured, honor the classifier's hint.
if ((!toolFilter || toolFilter.length === 0) &&
decision.suggestedTools &&
decision.suggestedTools.length > 0) {
toolFilter = [...decision.suggestedTools];
}
return { toolFilter, excludeTools };
}
/**
* Resolve cost/quality/capabilities for a member. Declared values win;
* gaps are filled from the model registry (by model name/alias) when known.
* Results are cached per (provider/model) for the router's lifetime.
*/
metaFor(member) {
const key = `${member.provider}::${member.model ?? ""}`;
const cached = this.metaCache.get(key);
if (cached) {
return cached;
}
let cost = member.cost;
let quality = member.quality;
let capabilities = member.capabilities
? [...member.capabilities]
: undefined;
const needsEnrichment = cost === undefined || quality === undefined || capabilities === undefined;
if (needsEnrichment && member.model) {
try {
const info = ModelResolver.resolveModel(member.model);
if (!info) {
this.deps.logger?.debug?.(`[ClassifierRouter] metaFor: no registry match for ${member.provider}/${member.model}`);
}
else {
if (cost === undefined) {
cost = info.pricing.inputCostPer1K + info.pricing.outputCostPer1K;
}
if (quality === undefined) {
quality = QUALITY_SCORE[info.performance.quality] ?? NEUTRAL;
}
if (capabilities === undefined) {
const caps = [];
if (info.capabilities.vision) {
caps.push("vision");
}
if (info.capabilities.functionCalling) {
caps.push("tools");
}
if (info.capabilities.reasoning) {
caps.push("reasoning");
}
if (info.capabilities.codeGeneration) {
caps.push("code");
}
if (info.capabilities.multimodal) {
caps.push("multimodal");
}
capabilities = caps;
}
}
}
catch (err) {
this.deps.logger?.warn?.(`[ClassifierRouter] metaFor: registry lookup threw for ${member.provider}/${member.model}`, { error: err instanceof Error ? err.message : String(err) });
}
}
const meta = { cost, quality, capabilities };
if (this.metaCache.size >= MAX_META_CACHE_ENTRIES) {
const oldest = this.metaCache.keys().next().value;
if (oldest !== undefined) {
this.metaCache.delete(oldest);
}
}
this.metaCache.set(key, meta);
return meta;
}
}