@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
243 lines (242 loc) • 11.2 kB
JavaScript
/**
* ModelPool runtime — multi-provider failover with per-member cooldown.
*
* This module is PURE (no provider imports, no circular dependencies).
* It works exclusively with provider NAMES + (provider, model, region) tuples;
* the existing AIProviderFactory creates actual provider instances.
*
* Usage:
* const pool = new ModelPool({ members: [...], strategy: "round-robin" });
* const member = pool.selectNext();
* try {
* await callProvider(member);
* pool.recordSuccess(member);
* } catch (err) {
* pool.recordFailure(member, classifyProviderError(err));
* // try next member ...
* }
*/
import { AuthenticationError, AuthorizationError, ModelAccessDeniedError, } from "../types/index.js";
import { looksLikeModelAccessDenied } from "../utils/providerErrorClassification.js";
// ─────────────────────────────────────────────────────────────────────────────
// Error classification
// ─────────────────────────────────────────────────────────────────────────────
/**
* Returns the HTTP status code embedded in an error object, or undefined.
*/
function extractHttpStatus(error) {
if (!error || typeof error !== "object") {
return undefined;
}
const e = error;
if (typeof e.status === "number") {
return e.status;
}
if (typeof e.statusCode === "number") {
return e.statusCode;
}
return undefined;
}
/**
* Classify a provider error into a coarse `ProviderErrorClass`.
*
* Rules (checked in order):
* 1. HTTP 429 or message pattern → "rate_limit"
* 2. HTTP 401/403, access-denied pattern, or auth keywords → "auth"
* 3. Context-window / token-limit message → "context_window"
* 4. HTTP 5xx or server-error message → "server"
* 5. Network connectivity error → "network"
* 6. Everything else → "unknown"
*/
export function classifyProviderError(error) {
const status = extractHttpStatus(error);
const msg = error instanceof Error
? error.message
: typeof error === "object" && error !== null
? String(error.message ?? error)
: String(error ?? "");
const lower = msg.toLowerCase();
// 1. Rate limit
if (status === 429 ||
/rate.?limit|quota exceeded|too many requests|requests per minute/.test(lower)) {
return "rate_limit";
}
// 2. Auth / access denied — also check typed error classes so that a
// typed AuthenticationError or ModelAccessDeniedError is classified as
// "auth" even when its message doesn't match the regex patterns.
if (status === 401 ||
status === 403 ||
error instanceof AuthenticationError ||
error instanceof AuthorizationError ||
error instanceof ModelAccessDeniedError ||
looksLikeModelAccessDenied(error) ||
/unauthori[sz]ed|forbidden|invalid api.?key|api key|access denied|authentication failed|permission denied|unauthenticated/.test(lower)) {
return "auth";
}
// 3. Context window
if (/context.?length|maximum context|token.*exceed|exceeds.*context|too.?long|input.*too.?large/.test(lower)) {
return "context_window";
}
// 4. Server error
if ((status !== undefined && status >= 500 && status < 600) ||
/server error|internal server|overloaded|service unavailable|bad gateway|gateway timeout/.test(lower)) {
return "server";
}
// 5. Network
if (/econnreset|etimedout|enotfound|network error|socket hang up|connection refused|connection reset/.test(lower)) {
return "network";
}
return "unknown";
}
// ─────────────────────────────────────────────────────────────────────────────
// Cooldown constants
// ─────────────────────────────────────────────────────────────────────────────
const DEFAULT_COOLDOWN_MS = 60_000; // 1 minute for retryable errors
// Non-retryable errors use a permanent-for-the-instance cooldown: 10 years.
// NOTE: Because ModelPool is stored as a long-lived field on the NeuroLink
// instance (neurolink.ts constructor), a "permanent" cooldown blocks the
// member for the entire lifetime of the NeuroLink instance, not just the
// current call. Only use PERMANENT_COOLDOWN_MS for error classes where the
// root cause is structural and will not resolve between calls (e.g. wrong API
// key, model not whitelisted for the team). Transient or unclassified errors
// must use the timed cooldown so a momentary failure doesn't permanently
// retire a healthy member.
const PERMANENT_COOLDOWN_MS = 10 * 365 * 24 * 60 * 60 * 1000;
/**
* Error classes that should receive a timed cooldown rather than a permanent
* ban. "unknown" is deliberately included: because the classification is
* uncertain, treating an unrecognised error as permanent could retire a
* healthy member due to a one-off network blip or a non-standard error
* message. A timed cooldown lets the member recover automatically.
*/
const RETRYABLE_ERROR_CLASSES = new Set([
"rate_limit",
"server",
"network",
"unknown",
]);
// ─────────────────────────────────────────────────────────────────────────────
// ModelPool
// ─────────────────────────────────────────────────────────────────────────────
/**
* Multi-provider pool with per-member cooldown and strategy-based selection.
*
* All state (cooldowns, cursor) is instance-local and resets on construction.
* Thread safety is not required — Node.js is single-threaded for async work.
*/
export class ModelPool {
config;
strategy;
cooldownMs;
state;
cursor;
now;
constructor(config, injectors) {
this.config = config;
this.strategy = config.strategy ?? "priority";
this.cooldownMs = config.cooldownMs ?? DEFAULT_COOLDOWN_MS;
this.now = injectors?.now ?? Date.now;
this.cursor = 0;
this.state = new Map();
}
/**
* The maximum number of attempts per call (pool config value or member count).
* Used by callers that drive the retry loop externally.
*/
get maxAttempts() {
return this.config.maxAttempts ?? this.config.members.length;
}
/**
* Returns a stable string key for a pool member.
* Format: `${provider}:${model ?? "*"}:${region ?? "*"}`
*/
memberKey(member) {
return `${member.provider}:${member.model ?? "*"}:${member.region ?? "*"}`;
}
/** Returns members whose cooldown has expired (or were never cooled). */
availableMembers() {
const now = this.now();
return this.config.members.filter((m) => {
const s = this.state.get(this.memberKey(m));
return !s || s.cooldownUntil <= now;
});
}
/**
* Selects the next member to try according to the configured strategy.
*
* @param excludedKeys — keys of members already attempted this call.
* @returns the chosen member, or undefined when all members are exhausted.
*/
selectNext(excludedKeys) {
const candidates = this.availableMembers().filter((m) => !excludedKeys?.has(this.memberKey(m)));
if (candidates.length === 0) {
return undefined;
}
switch (this.strategy) {
case "priority":
return candidates[0];
case "round-robin": {
// Advance cursor to the next candidate index in the ORIGINAL list order.
const allMembers = this.config.members;
const candidateSet = new Set(candidates.map((m) => this.memberKey(m)));
// Find the next member starting from cursor that is in candidateSet.
for (let i = 0; i < allMembers.length; i++) {
const idx = (this.cursor + i) % allMembers.length;
const member = allMembers[idx];
if (candidateSet.has(this.memberKey(member))) {
// Advance cursor past this pick for the next call.
this.cursor = (idx + 1) % allMembers.length;
return member;
}
}
return candidates[0];
}
case "weighted": {
// Deterministic weighted selection: compute cumulative weights, pick
// by cursor position (mod total weight) so repeated calls rotate.
const totalWeight = candidates.reduce((sum, m) => sum + (m.weight ?? 1), 0);
if (totalWeight <= 0) {
return candidates[0];
}
const pick = this.cursor % totalWeight;
let accumulated = 0;
for (const member of candidates) {
accumulated += member.weight ?? 1;
if (pick < accumulated) {
this.cursor = (this.cursor + 1) % Math.max(totalWeight, 1);
return member;
}
}
// Fallback (floating-point edge case)
this.cursor = (this.cursor + 1) % Math.max(totalWeight, 1);
return candidates[candidates.length - 1];
}
default:
return candidates[0];
}
}
/**
* Records a provider failure, setting a cooldown appropriate for the error class.
*
* - Retryable classes (rate_limit, server, network, unknown): timed cooldown
* for `cooldownMs` so the member can recover and be retried later.
* - Non-retryable classes (auth, context_window): permanent cooldown for the
* lifetime of this ModelPool instance, because these errors are structural
* and will not resolve between calls (wrong credentials, model not available
* in team whitelist, payload exceeds the model's context window).
*/
recordFailure(member, errorClass) {
const key = this.memberKey(member);
const cooldown = RETRYABLE_ERROR_CLASSES.has(errorClass)
? this.now() + this.cooldownMs
: this.now() + PERMANENT_COOLDOWN_MS;
this.state.set(key, { cooldownUntil: cooldown });
}
/**
* Records a successful response, clearing any existing cooldown for this member
* so it remains fully available.
*/
recordSuccess(member) {
this.state.delete(this.memberKey(member));
}
}