voyageai-cli
Version:
CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search
376 lines (331 loc) • 12.9 kB
JavaScript
'use strict';
const { cosineSimilarity } = require('./math');
const VALID_DIMENSIONS = [256, 512, 1024, 2048];
const VALID_QUANTIZATIONS = ['float32', 'int8', 'uint8', 'binary'];
const MAX_TEXT_LENGTH = 10000;
const MAX_SIMILARITY_TEXTS = 10;
/**
* Handle Nano API requests.
* Returns true if handled, false otherwise.
* @param {http.IncomingMessage} req
* @param {http.ServerResponse} res
* @param {Object} context - Dependencies injected for testability
*/
async function handleNanoRequest(req, res, context) {
const {
readJsonBody,
generateLocalEmbeddings,
checkPython,
checkVenv,
checkModel,
checkDevice,
} = context;
// GET /api/nano/status
if (req.method === 'GET' && req.url === '/api/nano/status') {
try {
const python = checkPython();
const venv = checkVenv();
const model = checkModel();
const device = checkDevice();
const components = {
python: {
ok: python.ok,
version: python.message,
...(python.ok ? {} : { hint: python.hint }),
},
venv: {
ok: venv.ok,
path: venv.message,
...(venv.ok ? {} : { hint: venv.hint }),
},
model: {
ok: model.ok,
path: model.message,
...(model.ok ? {} : { hint: model.hint }),
},
bridge: {
ok: device.ok,
...(device.ok ? {} : { hint: device.hint }),
},
};
const ready = python.ok && venv.ok && model.ok && device.ok;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ready, components }));
return true;
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message, code: 'NANO_UNKNOWN' }));
return true;
}
}
// POST /api/nano/embed
if (req.method === 'POST' && req.url === '/api/nano/embed') {
try {
const body = await readJsonBody(req);
// Input validation
if (!body.text || typeof body.text !== 'string') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'text is required and must be a string' }));
return true;
}
if (body.text.length > MAX_TEXT_LENGTH) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `text exceeds maximum length of ${MAX_TEXT_LENGTH} characters` }));
return true;
}
const dimension = body.dimension || 1024;
if (!VALID_DIMENSIONS.includes(dimension)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `dimension must be one of: ${VALID_DIMENSIONS.join(', ')}` }));
return true;
}
const quantization = body.quantization || 'float32';
if (!VALID_QUANTIZATIONS.includes(quantization)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `quantization must be one of: ${VALID_QUANTIZATIONS.join(', ')}` }));
return true;
}
// Check nano readiness
const venv = checkVenv();
const model = checkModel();
if (!venv.ok || !model.ok) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Nano bridge not ready', code: 'NANO_NOT_READY', hint: 'Run: vai nano setup' }));
return true;
}
const start = performance.now();
const result = await generateLocalEmbeddings([body.text], { dimensions: dimension, precision: quantization });
const latency_ms = Math.round(performance.now() - start);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
embedding: result.data[0].embedding,
dimension,
quantization,
model: 'voyage-4-nano',
latency_ms,
}));
return true;
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message, code: err.code || 'NANO_UNKNOWN' }));
return true;
}
}
// POST /api/nano/similarity
if (req.method === 'POST' && req.url === '/api/nano/similarity') {
try {
const body = await readJsonBody(req);
// Input validation
if (!Array.isArray(body.texts)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'texts is required and must be an array' }));
return true;
}
if (body.texts.length < 2 || body.texts.length > MAX_SIMILARITY_TEXTS) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `texts must contain 2-${MAX_SIMILARITY_TEXTS} strings` }));
return true;
}
for (let i = 0; i < body.texts.length; i++) {
if (typeof body.texts[i] !== 'string') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `texts[${i}] must be a string` }));
return true;
}
if (body.texts[i].length > MAX_TEXT_LENGTH) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `texts[${i}] exceeds maximum length of ${MAX_TEXT_LENGTH} characters` }));
return true;
}
}
const dimension = body.dimension || 1024;
if (!VALID_DIMENSIONS.includes(dimension)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `dimension must be one of: ${VALID_DIMENSIONS.join(', ')}` }));
return true;
}
// Check nano readiness
const venv = checkVenv();
const model = checkModel();
if (!venv.ok || !model.ok) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Nano bridge not ready', code: 'NANO_NOT_READY', hint: 'Run: vai nano setup' }));
return true;
}
const start = performance.now();
const result = await generateLocalEmbeddings(body.texts, { dimensions: dimension });
const latency_ms = Math.round(performance.now() - start);
// Compute NxN cosine similarity matrix
const embeddings = result.data.map(d => d.embedding);
const n = embeddings.length;
const matrix = [];
for (let i = 0; i < n; i++) {
const row = [];
for (let j = 0; j < n; j++) {
if (i === j) {
row.push(1.0);
} else {
row.push(cosineSimilarity(embeddings[i], embeddings[j]));
}
}
matrix.push(row);
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
texts: body.texts,
matrix,
dimension,
latency_ms,
}));
return true;
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message, code: err.code || 'NANO_UNKNOWN' }));
return true;
}
}
// POST /api/nano/dimensions
if (req.method === 'POST' && req.url === '/api/nano/dimensions') {
try {
const body = await readJsonBody(req);
// Input validation
if (!body.text || typeof body.text !== 'string') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'text is required and must be a string' }));
return true;
}
if (body.text.length > MAX_TEXT_LENGTH) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `text exceeds maximum length of ${MAX_TEXT_LENGTH} characters` }));
return true;
}
// Check nano readiness
const venv = checkVenv();
const model = checkModel();
if (!venv.ok || !model.ok) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Nano bridge not ready', code: 'NANO_NOT_READY', hint: 'Run: vai nano setup' }));
return true;
}
const start = performance.now();
const dimensions = {};
for (const dim of VALID_DIMENSIONS) {
const result = await generateLocalEmbeddings([body.text], { dimensions: dim });
const embedding = result.data[0].embedding;
// L2 norm
let sumSq = 0;
let nearZero = 0;
for (let i = 0; i < embedding.length; i++) {
sumSq += embedding[i] * embedding[i];
if (Math.abs(embedding[i]) < 1e-6) nearZero++;
}
dimensions[dim] = {
embedding,
norm: Math.sqrt(sumSq),
sparsity: nearZero / embedding.length,
};
}
const latency_ms = Math.round(performance.now() - start);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
text: body.text,
dimensions,
latency_ms,
}));
return true;
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message, code: err.code || 'NANO_UNKNOWN' }));
return true;
}
}
// POST /api/nano/crossbridge
if (req.method === 'POST' && req.url === '/api/nano/crossbridge') {
try {
const body = await readJsonBody(req);
// Input validation
if (!body.text || typeof body.text !== 'string') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'text is required and must be a string' }));
return true;
}
if (body.text.length > MAX_TEXT_LENGTH) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `text exceeds maximum length of ${MAX_TEXT_LENGTH} characters` }));
return true;
}
const dimension = body.dimension || 1024;
if (!VALID_DIMENSIONS.includes(dimension)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `dimension must be one of: ${VALID_DIMENSIONS.join(', ')}` }));
return true;
}
// Check nano readiness
const venv = checkVenv();
const model = checkModel();
if (!venv.ok || !model.ok) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Nano bridge not ready', code: 'NANO_NOT_READY', hint: 'Run: vai nano setup' }));
return true;
}
const totalStart = performance.now();
// Generate nano (local) embedding
const nanoStart = performance.now();
const nanoResult = await generateLocalEmbeddings([body.text], { dimensions: dimension });
const nanoLatency = Math.round(performance.now() - nanoStart);
const nanoEmbedding = nanoResult.data[0].embedding;
// Generate API (cloud) embedding
let apiEmbedding = null;
let apiModel = null;
let apiLatency = null;
let apiError = null;
let similarity = null;
const { generateCloudEmbeddings } = context;
if (generateCloudEmbeddings) {
try {
const apiStart = performance.now();
const apiResult = await generateCloudEmbeddings([body.text], {
outputDimension: dimension,
});
apiLatency = Math.round(performance.now() - apiStart);
apiEmbedding = apiResult.data[0].embedding;
apiModel = apiResult.model || 'voyage-3-large';
// Compute cosine similarity between nano and API vectors
similarity = cosineSimilarity(nanoEmbedding, apiEmbedding);
} catch (err) {
apiError = err.message || 'API embedding generation failed';
}
} else {
apiError = 'No API key configured';
}
const totalLatency = Math.round(performance.now() - totalStart);
const response = {
text: body.text,
dimension,
nano: {
embedding: nanoEmbedding,
model: 'voyage-4-nano',
latency_ms: nanoLatency,
},
api: apiEmbedding ? {
embedding: apiEmbedding,
model: apiModel,
latency_ms: apiLatency,
} : null,
apiError: apiError || undefined,
similarity,
latency_ms: totalLatency,
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(response));
return true;
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message, code: err.code || 'NANO_UNKNOWN' }));
return true;
}
}
// Not a nano endpoint
return false;
}
module.exports = { handleNanoRequest };