@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
295 lines (294 loc) • 14.7 kB
JavaScript
import { logger } from "../utils/logger.js";
import { suggestClosest } from "../utils/stringDistance.js";
import { PROVIDER_DESCRIPTORS, PROVIDER_DESCRIPTORS_BY_NAME, PROVIDER_ALIAS_INDEX, } from "./providerDescriptors.js";
// Pure factory pattern with no hardcoded imports
// All providers loaded dynamically via registry to avoid circular dependencies
/**
* Resolve a registered provider name (or alias) to its NeurolinkCredentials
* key. Backed by ProviderFactory.getDescriptor() — PROVIDER_DESCRIPTORS is
* the single source of truth for provider identity — instead of a
* hand-maintained map, so this can no longer drift out of sync with the
* descriptors, and it correctly resolves aliases (e.g. "hf") through
* PROVIDER_ALIAS_INDEX, not just canonical names. Falls back to the
* lowercased input when no descriptor is found, matching the retired
* map's behavior for unknown provider names.
*
* Referencing ProviderFactory here (defined further down this file) is
* safe: this function's body only runs when called, by which point the
* module has finished evaluating and ProviderFactory is fully defined —
* there is no temporal-dead-zone hazard from the declaration order.
*/
export function resolveCredentialKey(providerName) {
const normalized = providerName.toLowerCase();
return (ProviderFactory.getDescriptor(normalized)?.credentialsKey ?? normalized);
}
/**
* registerProvider() takes a class as well as a factory (ProviderConstructor),
* and calling a class without `new` throws. A prototype test cannot tell them
* apart, because every non-arrow function's prototype points back at itself,
* so the source text decides. A factory is therefore never constructed and
* never retried after it throws.
*/
function isClassConstructor(candidate) {
return (typeof candidate === "function" &&
/^class[\s{]/.test(Function.prototype.toString.call(candidate)));
}
/**
* True Factory Pattern implementation for AI Providers
* Uses registration-based approach to eliminate switch statements
* and enable dynamic provider registration
*/
export class ProviderFactory {
static providers = new Map();
static initialized = false;
/**
* Register a provider with the factory
*/
static registerProvider(name, constructor, defaultModel, // Optional - provider can read from env
aliases = [], descriptor) {
const registration = {
constructor,
defaultModel,
aliases,
descriptor,
};
// Register main name
ProviderFactory.providers.set(name.toLowerCase(), registration);
// Register aliases
aliases.forEach((alias) => {
ProviderFactory.providers.set(alias.toLowerCase(), registration);
});
logger.debug(`Registered provider: ${name} with model ${defaultModel || "from-env"}`);
}
/**
* Create a provider instance
* @param providerName - Provider name (optional, uses NEUROLINK_PROVIDER env var or 'vertex' as default)
* @param modelName - Model name (optional, uses provider-specific env var or registry default)
*/
static async createProvider(providerName, modelName, sdk, region, credentials) {
// Note: Providers are registered explicitly by ProviderRegistry to avoid circular dependencies
// Use environment variable or default if not specified
const resolvedProviderName = providerName ||
process.env.NEUROLINK_PROVIDER ||
process.env.AI_PROVIDER ||
"vertex";
const normalizedName = resolvedProviderName.toLowerCase();
const registration = ProviderFactory.providers.get(normalizedName);
if (!registration) {
throw new Error(ProviderFactory.describeUnresolvableProvider(resolvedProviderName));
}
// Everything below this point — model resolution included — is wrapped in
// the same try/catch. The dynamic import a few lines down is on this
// path too: a load failure there is a provider-construction failure like
// any other and must come back as "Failed to create provider ...", not
// escape unwrapped.
try {
// Respect environment variables before falling back to registry default
let model = modelName;
if (model) {
// #337: the model registry has always carried aliases ("gpt4o" ->
// "gpt-4o"), and capability lookups resolve through them, but the id
// actually sent to the provider did not — so `generate({ model:
// "gpt4o" })` put the alias on the wire and the provider 404'd.
// Imported lazily: the registry is a large module and this is the only
// thing on the construction path that needs it.
const { resolveProviderModelAlias } = await import("../models/modelRegistry.js");
const canonicalProvider = ProviderFactory.getDescriptor(normalizedName)?.name ?? normalizedName;
const canonicalModel = resolveProviderModelAlias(canonicalProvider, model);
if (canonicalModel) {
logger.debug("[ProviderFactory] Resolved model alias", {
provider: canonicalProvider,
requested: model,
resolved: canonicalModel,
});
model = canonicalModel;
}
}
if (!model) {
// Check for provider-specific environment variables
if (resolvedProviderName.toLowerCase().includes("vertex")) {
// Use gemini-2.5-flash as default - latest GA model with best price-performance
model = process.env.VERTEX_MODEL || "gemini-2.5-flash";
}
else if (resolvedProviderName.toLowerCase().includes("bedrock")) {
model = process.env.BEDROCK_MODEL || process.env.BEDROCK_MODEL_ID;
}
// Fallback to registry default if no env var
model = model || registration.defaultModel;
}
const credKey = resolveCredentialKey(normalizedName);
// Extract provider-scoped credential slice (e.g. credentials.openai for OpenAI)
const scopedCredentials = credentials
? credentials[credKey]
: undefined;
if (typeof registration.constructor !== "function") {
throw new Error(`Invalid constructor for provider ${providerName}: not a function`);
}
// A factory keeps being called as a method of the registration, as it
// always was; only a class is constructed.
const factoryResult = isClassConstructor(registration.constructor)
? new registration.constructor(model, resolvedProviderName, sdk, region, scopedCredentials)
: registration.constructor(model, resolvedProviderName, sdk, region, scopedCredentials);
const result = factoryResult instanceof Promise ? await factoryResult : factoryResult;
return result;
}
catch (error) {
logger.error(`Failed to create provider ${resolvedProviderName}:`, error);
throw new Error(`Failed to create provider ${resolvedProviderName}: ${error}`, { cause: error });
}
}
/**
* Check if a provider is registered
*/
static hasProvider(providerName) {
return ProviderFactory.providers.has(providerName.toLowerCase());
}
/**
* Get list of available providers
*/
/**
* Explain why a provider name did not resolve, usefully (#354).
*
* Three distinct situations used to share one generic message:
*
* - The registry is empty, because `ProviderRegistry.registerAllProviders()`
* has not run yet. Every name is unresolvable, and listing zero available
* providers tells the reader nothing about why.
* - The name IS a known provider but has no registration — recognised,
* unavailable. Distinct from a typo and worth saying so.
* - The name is not a provider at all, in which case a near-miss is almost
* always a typo and is worth naming: "opennai" -> "openai".
*
* Canonical names are offered as suggestions ahead of aliases, so a typo
* resolves to the name the docs use.
*
* @param requested - The provider name as the caller supplied it
* @returns A message naming the situation and what to do about it
*/
static describeUnresolvableProvider(requested) {
const registered = ProviderFactory.getAvailableProviders();
if (registered.length === 0) {
return (`Cannot resolve provider "${requested}": no providers are registered yet. ` +
`ProviderRegistry.registerAllProviders() has not completed, so every name ` +
`is unresolvable at this point — this is a lifecycle problem, not a bad name.`);
}
// `registered` mixes canonical names and aliases (registerProvider sets
// both as separate map keys — see registerProvider above), and a name
// registered without a descriptor (a caller-supplied registerProvider()
// with no 5th argument) has no entry in PROVIDER_ALIAS_INDEX at all. Only
// PROVIDER_DESCRIPTORS would previously stand in for "what's registered",
// which listed every built-in provider whether or not this factory
// instance had actually registered it, and mislabelled a descriptor-less
// custom registration as an "alias" simply for not matching that static
// list. Deriving both lists from `registered` itself fixes both: a
// registered name canonicalizes to what PROVIDER_ALIAS_INDEX resolves it
// to (itself, when it has no descriptor), and only a name that resolves
// to a *different*, registered canonical name is a true alias.
const canonical = Array.from(new Set(registered.map((name) => PROVIDER_ALIAS_INDEX.get(name) ?? name))).sort();
const aliases = registered
.filter((name) => {
const target = PROVIDER_ALIAS_INDEX.get(name);
return target !== undefined && target !== name;
})
.sort();
const known = PROVIDER_ALIAS_INDEX.get(requested.toLowerCase());
if (known) {
return (`Provider "${requested}" is a recognised provider (${known}) but is not registered ` +
`in this factory, so it cannot be constructed. Registered providers: ${canonical.join(", ")}.`);
}
// Canonical names first so a tie resolves to the documented spelling.
const suggestions = suggestClosest(requested, [...canonical, ...aliases]);
const didYouMean = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}?` : "";
return (`Unknown provider: "${requested}".${didYouMean} ` +
`Valid providers: ${canonical.join(", ")}. Accepted aliases: ${aliases.join(", ")}.`);
}
static getAvailableProviders() {
return Array.from(ProviderFactory.providers.keys()).filter((name, index, arr) => arr.indexOf(name) === index);
}
/**
* Get provider registration info
*/
static getProviderInfo(providerName) {
return ProviderFactory.providers.get(providerName.toLowerCase());
}
/**
* Look up a provider's static descriptor. Checks the built-in
* PROVIDER_DESCRIPTORS first (works even before registerAllProviders()
* has run, and resolves aliases via PROVIDER_ALIAS_INDEX), then falls
* back to whatever descriptor a live custom registration attached via
* registerProvider()'s 5th parameter.
*/
static getDescriptor(name) {
const normalized = name.toLowerCase();
const canonical = PROVIDER_ALIAS_INDEX.get(normalized);
if (canonical) {
const builtIn = PROVIDER_DESCRIPTORS_BY_NAME.get(canonical);
if (builtIn) {
return builtIn;
}
}
return ProviderFactory.providers.get(normalized)?.descriptor;
}
/** All built-in provider descriptors (does not include custom-registered providers that lack a descriptor). */
static getAllDescriptors() {
return PROVIDER_DESCRIPTORS;
}
/**
* Normalize provider names using aliases (PHASE 1: Factory Pattern)
*/
static normalizeProviderName(providerName) {
const normalized = providerName.toLowerCase();
if (ProviderFactory.providers.has(normalized)) {
return normalized;
}
const canonical = PROVIDER_ALIAS_INDEX.get(normalized);
if (canonical && ProviderFactory.providers.has(canonical)) {
return canonical;
}
// Fallback for providers registered without a built-in descriptor
// (e.g. TTS/STT/media handlers registered outside PROVIDER_DESCRIPTORS).
for (const [name, registration] of ProviderFactory.providers.entries()) {
if (registration.aliases?.includes(normalized)) {
return name;
}
}
return null;
}
/**
* Clear all registrations (mainly for testing)
*/
static clearRegistrations() {
ProviderFactory.providers.clear();
ProviderFactory.initialized = false;
}
/**
* Ensure providers are initialized
*/
static ensureInitialized() {
if (!ProviderFactory.initialized) {
ProviderFactory.initializeDefaultProviders();
ProviderFactory.initialized = true;
}
}
/**
* Initialize default providers
* NOTE: Providers are now registered by ProviderRegistry to avoid circular dependencies
*/
static initializeDefaultProviders() {
logger.debug("BaseProvider factory pattern ready - providers registered by ProviderRegistry");
// No hardcoded registrations - all done dynamically by ProviderRegistry
}
/**
* Create the best available provider for the given name
* Used by NeuroLink SDK for streaming and generation
*/
static async createBestProvider(providerName, modelName, enableMCP, sdk, credentials) {
return await ProviderFactory.createProvider(providerName, modelName, sdk, undefined, credentials);
}
}
/**
* Helper function to create providers with backward compatibility
*/
export async function createAIProvider(providerName, modelName, credentials) {
return await ProviderFactory.createProvider(providerName, modelName, undefined, undefined, credentials);
}