@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
287 lines (286 loc) • 11 kB
JavaScript
import { logger } from "../utils/logger.js";
/**
* Every shipped speech-to-text provider — the single source of truth for its
* name, aliases, credentials slice, env vars and capabilities. Pure data, like
* `PROVIDER_DESCRIPTORS` for text providers; the handler classes are wired to
* these names in `voice/index.ts`, and `MEDIA_HANDLER_CATALOG`'s STT rows are
* derived from this list so the two cannot drift.
*
* **Order is precedence.** `resolveDefaultSTTProvider()` returns the first
* entry that is configured, so reordering changes which engine every
* `transcribe()` / `generate({ stt })` without an explicit provider uses.
* `whistle` is last on purpose: it runs on this machine and needs no key, so
* it is what a caller gets when nothing else is configured.
*/
export const STT_PROVIDER_DESCRIPTORS = [
{
name: "whisper",
aliases: ["openai-stt", "openai-compatible"],
label: "OpenAI-compatible /audio/transcriptions",
credentialsKey: "whisper",
envVars: ["OPENAI_STT_API_KEY", "OPENAI_API_KEY"],
baseUrlEnv: "OPENAI_STT_BASE_URL",
defaultModel: "whisper-1",
capabilities: {
streaming: "chunked",
// OpenAI's own endpoint does not diarize; compatible servers may, and
// the handler only sends the switch to those.
diarization: true,
languageDetect: true,
prompt: true,
wordTimestamps: true,
local: false,
},
},
{
name: "deepgram",
label: "Deepgram",
credentialsKey: "deepgram",
envVars: ["DEEPGRAM_API_KEY"],
baseUrlEnv: "DEEPGRAM_BASE_URL",
defaultModel: "nova-2",
capabilities: {
streaming: "native",
diarization: true,
languageDetect: true,
prompt: false,
wordTimestamps: true,
local: false,
},
},
{
name: "elevenlabs-stt",
// "elevenlabs" is reused across kinds on purpose — TTS primary, STT
// alias. Each kind has its own registry, so `stt: { provider:
// "elevenlabs" }` and `tts: { provider: "elevenlabs" }` resolve to
// different handlers.
aliases: ["scribe", "elevenlabs"],
label: "ElevenLabs Scribe",
credentialsKey: "elevenlabs",
envVars: ["ELEVENLABS_API_KEY"],
baseUrlEnv: "ELEVENLABS_STT_BASE_URL",
defaultModel: "scribe_v2",
capabilities: {
streaming: "chunked",
diarization: true,
languageDetect: true,
prompt: false,
wordTimestamps: true,
local: false,
},
},
{
name: "google-stt",
label: "Google Cloud Speech-to-Text",
credentialsKey: "google",
// GoogleSTT accepts the AI Studio key aliases and a service-account file.
envVars: [
"GOOGLE_API_KEY",
"GOOGLE_AI_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_APPLICATION_CREDENTIALS",
],
defaultModel: "latest_long",
capabilities: {
streaming: "chunked",
diarization: true,
languageDetect: false,
prompt: false,
wordTimestamps: true,
local: false,
},
},
{
name: "azure-stt",
label: "Azure Speech",
credentialsKey: "azure",
envVars: ["AZURE_SPEECH_KEY"],
capabilities: {
streaming: "chunked",
diarization: false,
languageDetect: false,
prompt: false,
wordTimestamps: true,
local: false,
},
},
{
name: "whistle",
aliases: ["cactus-whistle", "local"],
label: "Whistle (built-in, local)",
credentialsKey: "whistle",
envVars: [],
capabilities: {
streaming: "chunked",
diarization: false,
languageDetect: true,
prompt: false,
wordTimestamps: true,
local: true,
languages: ["en", "de", "fr", "es", "it", "nl", "pl"],
},
},
];
/** The provider used when nothing else is configured. */
export const DEFAULT_LOCAL_STT_PROVIDER = "whistle";
const STT_DESCRIPTORS_BY_NAME = new Map(STT_PROVIDER_DESCRIPTORS.map((d) => [d.name, d]));
/** Lower-cased name or alias → canonical descriptor name. */
const STT_ALIAS_INDEX = new Map(STT_PROVIDER_DESCRIPTORS.flatMap((d) => [
[d.name.toLowerCase(), d.name],
...(d.aliases ?? []).map((alias) => [alias.toLowerCase(), d.name]),
]));
/**
* Canonical descriptor name for a provider name or alias (case-insensitive),
* or `undefined` when it is not a shipped STT provider — a caller may still
* have registered a handler of its own under that name.
*/
export function resolveSTTProviderName(name) {
if (!name) {
return undefined;
}
return STT_ALIAS_INDEX.get(name.trim().toLowerCase());
}
/** The descriptor for a provider name or alias, if it is a shipped one. */
export function getSTTProviderDescriptor(name) {
const canonical = resolveSTTProviderName(name);
return canonical ? STT_DESCRIPTORS_BY_NAME.get(canonical) : undefined;
}
function hasValue(value) {
return typeof value === "string" && value.trim().length > 0;
}
/**
* Whether a credentials slice configures its provider: a key, a service
* account file, or — for the OpenAI-compatible handler, whose self-hosted
* servers often need no key — a base URL.
*/
function sliceConfigures(descriptor, credentials) {
const slice = credentials?.[descriptor.credentialsKey];
if (!slice) {
return false;
}
if (hasValue(slice.apiKey) || hasValue(slice.credentialsPath)) {
return true;
}
return descriptor.credentialsKey === "whisper" && hasValue(slice.baseURL);
}
/**
* Whether a provider can run with these credentials and the environment. The
* local engine needs no configuration and always counts. Unknown names are
* not configured.
*/
export function isSTTProviderConfigured(name, credentials) {
const descriptor = getSTTProviderDescriptor(name);
if (!descriptor) {
return false;
}
if (descriptor.capabilities.local) {
return true;
}
if (sliceConfigures(descriptor, credentials)) {
return true;
}
return descriptor.envVars.some((envVar) => hasValue(process.env[envVar]));
}
/** Configured providers, in precedence order (the local engine last). */
export function listConfiguredSTTProviders(credentials) {
return [
...STT_PROVIDER_DESCRIPTORS.filter((d) => isSTTProviderConfigured(d.name, credentials)).map((d) => d.name),
...Object.keys(resolveSTTEndpoints(credentials)),
];
}
/**
* Named OpenAI-compatible endpoints: `credentials.stt.endpoints` over the
* `NEUROLINK_STT_ENDPOINTS` JSON object. Keys are lowercased; one that
* collides with a shipped provider name or alias is dropped with a warning,
* as is an entry without a `baseURL`. Malformed JSON is reported once and
* ignored.
*/
export function resolveSTTEndpoints(credentials) {
const out = {};
const raw = process.env.NEUROLINK_STT_ENDPOINTS?.trim();
if (raw) {
try {
Object.assign(out, validateSTTEndpoints("NEUROLINK_STT_ENDPOINTS", JSON.parse(raw)));
}
catch (error) {
logger.warn(`[STT] NEUROLINK_STT_ENDPOINTS is not valid JSON; ignoring it (${error instanceof Error ? error.message : String(error)})`);
}
}
Object.assign(out, resolveCallSTTEndpoints(credentials));
return out;
}
/**
* The per-call endpoints alone (`credentials.stt.endpoints`), validated
* exactly as the env map is: names lower-cased, a name that collides with a
* shipped provider or alias dropped, an entry without a `baseURL` dropped.
* Every per-call lookup goes through this so a request can neither replace a
* shipped handler nor build an endpoint the env form would have refused.
*/
export function resolveCallSTTEndpoints(credentials) {
return validateSTTEndpoints("credentials.stt.endpoints", credentials?.endpoints);
}
function validateSTTEndpoints(source, entries) {
const out = {};
if (typeof entries !== "object" || entries === null) {
return out;
}
for (const [rawName, cfg] of Object.entries(entries)) {
const name = rawName.trim().toLowerCase();
if (!name) {
continue;
}
if (STT_ALIAS_INDEX.has(name)) {
logger.warn(`[STT] ${source}: endpoint "${rawName}" collides with a shipped provider name; ignoring it`);
continue;
}
const baseURL = typeof cfg === "object" && cfg !== null && "baseURL" in cfg
? cfg.baseURL
: undefined;
if (typeof baseURL !== "string" || !baseURL.trim()) {
logger.warn(`[STT] ${source}: endpoint "${rawName}" has no baseURL; ignoring it`);
continue;
}
const c = cfg;
out[name] = {
baseURL: baseURL.trim(),
...(typeof c.apiKey === "string" ? { apiKey: c.apiKey } : {}),
...(typeof c.model === "string" ? { model: c.model } : {}),
...(typeof c.timeoutMs === "number" ? { timeoutMs: c.timeoutMs } : {}),
};
}
return out;
}
/**
* The provider a transcription without an explicit one goes to:
* `NEUROLINK_STT_PROVIDER` when it names a known provider or alias, else the
* first configured provider in descriptor order, else the built-in local
* engine. Never throws, never returns an empty string.
*/
export function resolveDefaultSTTProvider(credentials) {
const fromEnv = process.env.NEUROLINK_STT_PROVIDER?.trim();
if (fromEnv) {
const canonical = resolveSTTProviderName(fromEnv);
if (canonical) {
return canonical;
}
const endpoint = fromEnv.toLowerCase();
if (endpoint in resolveSTTEndpoints(credentials)) {
return endpoint;
}
logger.warn(`[STT] NEUROLINK_STT_PROVIDER="${fromEnv}" is not a known STT provider; ` +
`ignoring it. Known: ${STT_PROVIDER_DESCRIPTORS.map((d) => d.name).join(", ")}`);
}
const configured = STT_PROVIDER_DESCRIPTORS.find((d) => !d.capabilities.local && isSTTProviderConfigured(d.name, credentials));
return configured?.name ?? DEFAULT_LOCAL_STT_PROVIDER;
}
/**
* The variables that would configure each hosted STT provider, one clause per
* provider in precedence order, e.g. "OPENAI_STT_API_KEY or OPENAI_API_KEY for
* whisper, or DEEPGRAM_API_KEY for deepgram". Derived from the descriptors so
* a new provider appears in every "nothing is configured" message.
*/
export function describeSTTProviderKeys() {
return STT_PROVIDER_DESCRIPTORS.filter((d) => d.envVars.length > 0)
.map((d) => `${d.envVars.join(" or ")} for ${d.name}`)
.join(", or ");
}