UNPKG

voyageai-cli

Version:

CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search

213 lines (186 loc) 8.85 kB
'use strict'; const { MODEL_CATALOG } = require('../lib/catalog'); const ui = require('../lib/ui'); // Average tokens per document/query (rough industry estimates) const DEFAULT_DOC_TOKENS = 500; const DEFAULT_QUERY_TOKENS = 30; /** * Parse a shorthand number: "1M" → 1000000, "500K" → 500000, "1B" → 1000000000. * @param {string} val * @returns {number} */ function parseShorthand(val) { if (!val) return NaN; const str = String(val).trim().toUpperCase(); const multipliers = { K: 1e3, M: 1e6, B: 1e9, T: 1e12 }; const match = str.match(/^([\d.]+)\s*([KMBT])?$/); if (!match) return parseFloat(str); const num = parseFloat(match[1]); const suffix = match[2]; return suffix ? num * multipliers[suffix] : num; } /** * Format a number with commas: 1234567 → "1,234,567". */ function formatNum(n) { return n.toLocaleString('en-US'); } /** * Format dollars: 0.50 → "$0.50", 1234.56 → "$1,234.56". */ function formatDollars(n) { if (n < 0.01 && n > 0) return `$${n.toFixed(4)}`; if (n < 1) return `$${n.toFixed(2)}`; return '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } /** * Format a large number in short form: 1000000 → "1M". */ function shortNum(n) { if (n >= 1e9) return (n / 1e9).toFixed(n % 1e9 === 0 ? 0 : 1) + 'B'; if (n >= 1e6) return (n / 1e6).toFixed(n % 1e6 === 0 ? 0 : 1) + 'M'; if (n >= 1e3) return (n / 1e3).toFixed(n % 1e3 === 0 ? 0 : 1) + 'K'; return String(n); } /** * Register the estimate command on a Commander program. * @param {import('commander').Command} program */ function registerEstimate(program) { program .command('estimate') .description('Estimate embedding costs — symmetric vs asymmetric strategies') .option('--docs <n>', 'Number of documents to embed (supports K/M/B shorthand)', '100K') .option('--queries <n>', 'Number of queries per month (supports K/M/B shorthand)', '1M') .option('--doc-tokens <n>', 'Average tokens per document', String(DEFAULT_DOC_TOKENS)) .option('--query-tokens <n>', 'Average tokens per query', String(DEFAULT_QUERY_TOKENS)) .option('--doc-model <model>', 'Model for document embedding (asymmetric)', 'voyage-4-large') .option('--query-model <model>', 'Model for query embedding (asymmetric)', 'voyage-4-lite') .option('--months <n>', 'Months to project', '12') .option('--json', 'Machine-readable JSON output') .option('-q, --quiet', 'Suppress non-essential output') .action((opts) => { const telemetry = require('../lib/telemetry'); const numDocs = parseShorthand(opts.docs); const numQueries = parseShorthand(opts.queries); const docTokens = parseInt(opts.docTokens, 10) || DEFAULT_DOC_TOKENS; const queryTokens = parseInt(opts.queryTokens, 10) || DEFAULT_QUERY_TOKENS; const months = parseInt(opts.months, 10) || 12; if (isNaN(numDocs) || isNaN(numQueries)) { console.error(ui.error('Invalid --docs or --queries value. Use numbers or shorthand (e.g., 1M, 500K).')); process.exit(1); } // Get model prices const v4Models = MODEL_CATALOG.filter(m => m.sharedSpace === 'voyage-4' && m.pricePerMToken != null); const docModel = MODEL_CATALOG.find(m => m.name === opts.docModel); const queryModel = MODEL_CATALOG.find(m => m.name === opts.queryModel); if (!docModel || docModel.pricePerMToken == null) { console.error(ui.error(`Unknown or unpriced model: ${opts.docModel}`)); process.exit(1); } if (!queryModel || queryModel.pricePerMToken == null) { console.error(ui.error(`Unknown or unpriced model: ${opts.queryModel}`)); process.exit(1); } telemetry.send('cli_estimate', { model: opts.docModel, tokenCount: numDocs * docTokens }); const docTotalTokens = numDocs * docTokens; const queryTotalTokensPerMonth = numQueries * queryTokens; // Calculate costs for different strategies const strategies = []; // Strategy 1: Symmetric with each V4 model for (const model of v4Models) { if (model.pricePerMToken === 0) continue; // skip free models for symmetric const docCost = (docTotalTokens / 1e6) * model.pricePerMToken; const queryCostPerMonth = (queryTotalTokensPerMonth / 1e6) * model.pricePerMToken; const totalCost = docCost + (queryCostPerMonth * months); strategies.push({ name: `Symmetric: ${model.name}`, type: 'symmetric', docModel: model.name, queryModel: model.name, docCost, queryCostPerMonth, totalCost, months, }); } // Strategy 2: Asymmetric — user-specified doc+query combo const asymDocCost = (docTotalTokens / 1e6) * docModel.pricePerMToken; const asymQueryCostPerMonth = (queryTotalTokensPerMonth / 1e6) * queryModel.pricePerMToken; const asymTotalCost = asymDocCost + (asymQueryCostPerMonth * months); strategies.push({ name: `Asymmetric: ${docModel.name} docs + ${queryModel.name} queries`, type: 'asymmetric', docModel: docModel.name, queryModel: queryModel.name, docCost: asymDocCost, queryCostPerMonth: asymQueryCostPerMonth, totalCost: asymTotalCost, months, recommended: true, }); // Strategy 3: Asymmetric with nano queries (if doc model isn't nano) if (opts.queryModel !== 'voyage-4-nano') { const nanoModel = MODEL_CATALOG.find(m => m.name === 'voyage-4-nano'); if (nanoModel) { strategies.push({ name: `Asymmetric: ${docModel.name} docs + voyage-4-nano queries (local)`, type: 'asymmetric-local', docModel: docModel.name, queryModel: 'voyage-4-nano', docCost: asymDocCost, queryCostPerMonth: 0, totalCost: asymDocCost, months, }); } } // Sort by total cost strategies.sort((a, b) => a.totalCost - b.totalCost); if (opts.json) { console.log(JSON.stringify({ params: { docs: numDocs, queries: numQueries, docTokens, queryTokens, months }, strategies, }, null, 2)); return; } // Find the most expensive for savings comparison const maxCost = Math.max(...strategies.map(s => s.totalCost)); if (!opts.quiet) { console.log(ui.bold('💰 Voyage AI Cost Estimator')); console.log(''); console.log(ui.label('Documents', `${shortNum(numDocs)} × ${formatNum(docTokens)} tokens = ${shortNum(docTotalTokens)} tokens (one-time)`)); console.log(ui.label('Queries', `${shortNum(numQueries)}/mo × ${formatNum(queryTokens)} tokens = ${shortNum(queryTotalTokensPerMonth)} tokens/mo`)); console.log(ui.label('Projection', `${months} months`)); console.log(''); } console.log(ui.bold('Strategy Comparison:')); console.log(''); for (const s of strategies) { const savings = maxCost > 0 ? ((1 - s.totalCost / maxCost) * 100) : 0; const savingsStr = savings > 0 ? ui.green(` (${savings.toFixed(0)}% savings)`) : ''; const marker = s.recommended ? ui.cyan(' ★ recommended') : ''; const localNote = s.type === 'asymmetric-local' ? ui.dim(' (query cost = $0, runs locally)') : ''; console.log(` ${s.recommended ? ui.cyan('►') : ' '} ${ui.bold(s.name)}${marker}`); console.log(` Doc embedding: ${formatDollars(s.docCost)} ${ui.dim('(one-time)')}`); console.log(` Query cost: ${formatDollars(s.queryCostPerMonth)}/mo${localNote}`); console.log(` ${months}-mo total: ${ui.bold(formatDollars(s.totalCost))}${savingsStr}`); console.log(''); } // Show the asymmetric advantage const symmetricLarge = strategies.find(s => s.type === 'symmetric' && s.docModel === 'voyage-4-large'); const asymmetric = strategies.find(s => s.recommended); if (symmetricLarge && asymmetric && symmetricLarge.totalCost > asymmetric.totalCost) { const saved = symmetricLarge.totalCost - asymmetric.totalCost; const pct = ((saved / symmetricLarge.totalCost) * 100).toFixed(0); console.log(ui.success(`Asymmetric retrieval saves ${formatDollars(saved)} (${pct}%) over symmetric voyage-4-large`)); console.log(ui.dim(' Same document quality — lower query costs. Shared embedding space makes this possible.')); console.log(''); } if (!opts.quiet) { console.log(ui.dim('Tip: Use --doc-model and --query-model to compare any combination.')); console.log(ui.dim(' Use "vai explain shared-space" to learn about asymmetric retrieval.')); } }); } module.exports = { registerEstimate };