@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
167 lines (166 loc) • 7.57 kB
JavaScript
import { VoyageModels } from "../constants/enums.js";
import { BaseProvider } from "../core/baseProvider.js";
import { isNeuroLink } from "../neurolink.js";
import { parseIndexedEmbeddingsResponse } from "./embeddingResponseParsing.js";
import { createProxyFetch } from "../proxy/proxyFetch.js";
import { AuthenticationError, InvalidModelError, ProviderError, RateLimitError, } from "../types/index.js";
import { withTimeout } from "../utils/errorHandling.js";
import { logger } from "../utils/logger.js";
import { createVoyageConfig, getProviderModel, validateApiKey, } from "../utils/providerConfig.js";
const VOYAGE_DEFAULT_BASE_URL = "https://api.voyageai.com/v1";
const REQUEST_TIMEOUT_MS = 60_000;
const getVoyageApiKey = () => validateApiKey(createVoyageConfig());
const getDefaultVoyageModel = () => getProviderModel("VOYAGE_MODEL", VoyageModels.VOYAGE_3_5);
/**
* Voyage AI Provider — embedding-only.
*
* Top-tier RAG embedder. Native API at api.voyageai.com/v1/embeddings.
* Chat / streaming / tool calling are not supported — `executeStream` and
* `getAISDKModel` throw a friendly error so callers get an actionable
* message instead of a runtime crash deep in the streaming layer.
*
* @see https://docs.voyageai.com/docs/embeddings
*/
export class VoyageProvider extends BaseProvider {
apiKey;
baseURL;
proxyFetch;
constructor(modelName, sdk, _region, credentials) {
const validatedNeurolink = isNeuroLink(sdk) ? sdk : undefined;
super(modelName, "voyage", validatedNeurolink);
const overrideKey = credentials?.apiKey?.trim();
this.apiKey =
overrideKey && overrideKey.length > 0 ? overrideKey : getVoyageApiKey();
this.baseURL =
credentials?.baseURL ??
process.env.VOYAGE_BASE_URL ??
VOYAGE_DEFAULT_BASE_URL;
this.proxyFetch = createProxyFetch();
logger.debug("Voyage Provider initialized (embeddings only)", {
modelName: this.modelName,
baseURL: this.baseURL,
});
}
// ===== Required abstract overrides =====
getProviderName() {
return this.providerName;
}
getDefaultModel() {
return getDefaultVoyageModel();
}
supportsTools() {
return false;
}
getDefaultEmbeddingModel() {
return getDefaultVoyageModel();
}
/**
* Voyage is embedding-only — chat models do not exist on this endpoint.
* Caller surface stays consistent: returns an `AbortError`-shaped failure
* via `BaseProvider.handleProviderError`, not a TypeScript-level cast.
*/
getAISDKModel() {
throw new ProviderError("Voyage AI is an embedding-only provider; chat completions are not available. Use `embed()` or `embedMany()` instead, or pick a different provider for `generate()` / `stream()`.", "voyage");
}
async executeStream(_options, _analysisSchema) {
throw new ProviderError("Voyage AI is an embedding-only provider; streaming chat is not available. Use `embed()` / `embedMany()`, or pick another provider for `stream()`.", "voyage");
}
formatProviderError(error) {
const message = error instanceof Error
? error.message
: typeof error === "string"
? error
: "Unknown error";
if (message.includes("401") ||
message.toLowerCase().includes("unauthorized") ||
message.includes("invalid_api_key")) {
return new AuthenticationError("Invalid Voyage AI API key. Get one at https://dash.voyageai.com/api-keys", "voyage");
}
if (message.includes("429") ||
message.toLowerCase().includes("rate limit")) {
return new RateLimitError("Voyage AI rate limit exceeded. Back off and retry.", "voyage");
}
if (message.includes("404") ||
message.toLowerCase().includes("model_not_found")) {
return new InvalidModelError(`Voyage AI model '${this.modelName}' not found. Browse https://docs.voyageai.com/docs/embeddings`, "voyage");
}
return new ProviderError(`Voyage AI error: ${message}`, "voyage");
}
// ===== Embedding implementations =====
async embed(input, modelName) {
if (typeof input !== "string" && input.image) {
throw new ProviderError(`${this.providerName} does not support image embeddings; provide text input`, this.providerName);
}
const text = typeof input === "string" ? input : (input.text ?? "");
const vectors = await this.callEmbeddings([text], modelName);
if (!vectors[0]) {
throw new ProviderError("Voyage AI returned no embedding for the provided text", "voyage");
}
return vectors[0];
}
async embedMany(texts, modelName) {
if (texts.length === 0) {
return [];
}
// Voyage AI's /embeddings endpoint accepts up to 128 inputs per request.
// Split larger payloads into sequential batches to avoid API rejection.
const VOYAGE_MAX_BATCH_SIZE = 128;
const out = [];
for (let i = 0; i < texts.length; i += VOYAGE_MAX_BATCH_SIZE) {
const batch = texts.slice(i, i + VOYAGE_MAX_BATCH_SIZE);
const vectors = await this.callEmbeddings(batch, modelName);
out.push(...vectors);
}
return out;
}
/**
* POST /embeddings — Voyage accepts up to 128 inputs per request.
* Caller batches above that (see `embedMany`).
*/
async callEmbeddings(inputs, modelName) {
const model = modelName ?? this.modelName;
let response;
try {
response = await withTimeout(this.proxyFetch(`${this.baseURL}/embeddings`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
input: inputs,
model,
}),
}), REQUEST_TIMEOUT_MS, new ProviderError(`Voyage embeddings request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`, "voyage"));
}
catch (err) {
// Re-throw typed provider errors produced by withTimeout (ProviderError
// subclasses: AuthenticationError, RateLimitError, InvalidModelError)
// so they are not double-wrapped by formatProviderError.
if (err instanceof ProviderError) {
throw err;
}
throw this.formatProviderError(err);
}
if (!response.ok) {
const text = await response.text();
throw this.formatProviderError(new Error(`Voyage embeddings failed: ${response.status} — ${text}`));
}
const data = (await response.json());
// Sorts by index (Voyage returns out-of-order under some conditions) and
// verifies complete 0..n-1 coverage (Voyage may return partial results
// in edge-case error scenarios).
return parseIndexedEmbeddingsResponse(data, inputs.length, "Voyage", (message) => new ProviderError(message, "voyage"));
}
async validateConfiguration() {
return typeof this.apiKey === "string" && this.apiKey.trim().length > 0;
}
getConfiguration() {
return {
provider: this.providerName,
model: this.modelName,
defaultModel: getDefaultVoyageModel(),
baseURL: this.baseURL,
};
}
}