voyageai-cli
Version:
CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search
353 lines (307 loc) • 11.8 kB
JavaScript
;
/**
* Token and cost estimation utilities.
*
* Shared by all commands that support --estimate.
* Uses the model catalog for pricing and a ~4 chars/token heuristic.
*/
const { MODEL_CATALOG } = require('./catalog');
/**
* Estimate token count from text (~4 chars per token).
* @param {string} text
* @returns {number}
*/
function estimateTokens(text) {
if (!text) return 0;
return Math.ceil(text.length / 4);
}
/**
* Estimate tokens from an array of texts.
* @param {string[]} texts
* @returns {number}
*/
function estimateTokensForTexts(texts) {
return texts.reduce((sum, t) => sum + estimateTokens(t), 0);
}
/**
* Look up per-million-token price for a model.
* @param {string} modelName
* @returns {number|null} price per 1M tokens, or null if unknown
*/
function getModelPrice(modelName) {
const model = MODEL_CATALOG.find(m => m.name === modelName);
return model?.pricePerMToken ?? null;
}
/**
* Calculate estimated cost.
* @param {number} tokens - estimated token count
* @param {string} modelName - model name from catalog
* @returns {{ tokens: number, cost: number|null, model: string, pricePerMToken: number|null }}
*/
function estimateCost(tokens, modelName) {
const pricePerMToken = getModelPrice(modelName);
const cost = pricePerMToken != null ? (tokens / 1_000_000) * pricePerMToken : null;
return { tokens, cost, model: modelName, pricePerMToken };
}
/**
* Estimate cost for a chat turn (embedding query + reranking + LLM generation).
* @param {object} params
* @param {string} params.query - user's question text
* @param {number} params.contextDocs - number of context docs
* @param {number} params.avgDocTokens - average tokens per context doc (default 200)
* @param {string} params.embeddingModel - Voyage embedding model
* @param {string} params.rerankModel - Voyage rerank model (optional)
* @param {string} params.llmProvider - 'anthropic' | 'openai' | 'ollama'
* @param {string} params.llmModel - specific LLM model name
* @param {number} params.historyTurns - number of conversation turns in context (default 0)
* @returns {object} breakdown with per-stage estimates
*/
function estimateChatCost({
query,
contextDocs = 5,
avgDocTokens = 200,
embeddingModel = 'voyage-4-large',
rerankModel = 'rerank-2.5',
llmProvider = 'anthropic',
llmModel,
historyTurns = 0,
}) {
const queryTokens = estimateTokens(query);
const contextTokens = contextDocs * avgDocTokens;
const historyTokens = historyTurns * 150; // ~150 tokens per turn pair
const systemPromptTokens = 100; // rough estimate
// Stage 1: Embedding the query
const embedCost = estimateCost(queryTokens, embeddingModel);
// Stage 2: Reranking candidates
const rerankTokens = queryTokens + (contextDocs * avgDocTokens);
const rerankCost = rerankModel ? estimateCost(rerankTokens, rerankModel) : null;
// Stage 3: LLM generation
const llmInputTokens = systemPromptTokens + contextTokens + historyTokens + queryTokens;
const llmOutputTokens = 300; // estimated response length
const llmCost = estimateLLMCost(llmProvider, llmModel, llmInputTokens, llmOutputTokens);
const totalCost = (embedCost.cost || 0)
+ (rerankCost?.cost || 0)
+ (llmCost?.cost || 0);
return {
embed: embedCost,
rerank: rerankCost,
llm: llmCost,
totalTokens: queryTokens + rerankTokens + llmInputTokens + llmOutputTokens,
totalCost,
};
}
/**
* Rough LLM cost estimation (cloud providers only).
* @param {string} provider
* @param {string} model
* @param {number} inputTokens
* @param {number} outputTokens
* @returns {{ inputTokens: number, outputTokens: number, cost: number|null, model: string }}
*/
function estimateLLMCost(provider, model, inputTokens, outputTokens) {
// Approximate pricing per 1M tokens (input/output)
const LLM_PRICING = {
anthropic: {
'claude-sonnet-4-5-20250929': { input: 3.0, output: 15.0 },
'claude-opus-4-20250514': { input: 15.0, output: 75.0 },
'claude-3-5-haiku-20241022': { input: 1.0, output: 5.0 },
},
openai: {
'gpt-4o': { input: 2.5, output: 10.0 },
'gpt-4o-mini': { input: 0.15, output: 0.6 },
'gpt-4-turbo': { input: 10.0, output: 30.0 },
'o1': { input: 15.0, output: 60.0 },
'o1-mini': { input: 3.0, output: 12.0 },
'o3-mini': { input: 1.1, output: 4.4 },
},
ollama: {}, // all free
};
const providerPricing = LLM_PRICING[provider] || {};
const modelPricing = providerPricing[model];
let cost = null;
if (provider === 'ollama') {
cost = 0;
} else if (modelPricing) {
cost = (inputTokens / 1_000_000) * modelPricing.input
+ (outputTokens / 1_000_000) * modelPricing.output;
}
return { inputTokens, outputTokens, cost, model: model || 'unknown' };
}
/**
* Estimate cost across all comparable Voyage models.
* @param {number} tokens - estimated token count
* @param {string} selectedModel - the user's chosen model
* @returns {Array<{ model: string, tokens: number, cost: number, pricePerMToken: number, selected: boolean, shortFor: string }>}
*/
function estimateCostComparison(tokens, selectedModel) {
// Find the type of the selected model to compare apples-to-apples
const selected = MODEL_CATALOG.find(m => m.name === selectedModel);
const type = selected?.type || 'embedding';
// For embeddings, only show general-purpose models (voyage-4 family)
// plus the selected model. Skip domain-specific (finance, law, code)
// unless the user explicitly selected one of those.
const isGeneralPurpose = (m) => {
if (m.name === selectedModel) return true; // always include selected
if (type !== 'embedding') return true; // no filtering for rerank etc.
// Domain-specific models have specific bestFor keywords
const dominated = ['finance', 'legal', 'code', 'context'];
return !dominated.some(d => (m.bestFor || '').toLowerCase().includes(d));
};
return MODEL_CATALOG
.filter(m => m.type === type && !m.legacy && !m.unreleased && m.pricePerMToken != null && isGeneralPurpose(m))
.map(m => ({
model: m.name,
tokens,
cost: (tokens / 1_000_000) * m.pricePerMToken,
pricePerMToken: m.pricePerMToken,
selected: m.name === selectedModel,
shortFor: m.shortFor || m.bestFor || '',
}))
.sort((a, b) => b.pricePerMToken - a.pricePerMToken); // highest price first
}
/**
* Format a cost estimate for terminal display with model comparison.
* @param {object} estimate - from estimateCost()
* @returns {string}
*/
function formatCostEstimate(estimate) {
const pc = require('picocolors');
const lines = [];
const comparison = estimateCostComparison(estimate.tokens, estimate.model);
lines.push(pc.bold(` Cost Estimate — ${estimate.tokens.toLocaleString()} tokens`));
lines.push('');
if (comparison.length > 1) {
// Table header
lines.push(` ${pc.dim(padRight('Model', 22))} ${pc.dim(padRight('Quality', 14))} ${pc.dim(padRight('Price/1M', 10))} ${pc.dim('Est. Cost')}`);
lines.push(` ${pc.dim('─'.repeat(60))}`);
for (const row of comparison) {
const costStr = row.cost < 0.001 ? '< $0.001' : `$${row.cost.toFixed(4)}`;
const marker = row.selected ? pc.green(' ← selected') : '';
const nameStr = row.selected ? pc.bold(row.model) : row.model;
lines.push(` ${padRight(nameStr, 22)} ${padRight(row.shortFor, 14)} $${padRight(row.pricePerMToken.toFixed(2), 9)} ${pc.cyan(costStr)}${marker}`);
}
} else {
// Single model fallback
lines.push(` Model: ${estimate.model}`);
if (estimate.cost != null) {
lines.push(` Cost: ${pc.cyan(`$${estimate.cost.toFixed(4)}`)}`);
} else {
lines.push(` Cost: unknown pricing`);
}
}
return lines.join('\n');
}
function padRight(str, len) {
// Strip ANSI for length calculation
const stripped = str.replace(/\x1b\[[0-9;]*m/g, '');
const pad = Math.max(0, len - stripped.length);
return str + ' '.repeat(pad);
}
/**
* Format a chat cost breakdown for terminal display.
* @param {object} breakdown - from estimateChatCost()
* @returns {string}
*/
function formatChatCostBreakdown(breakdown) {
const pc = require('picocolors');
const lines = [];
lines.push(pc.bold(' Chat Cost Estimate (per turn)'));
lines.push(` ${pc.dim('─'.repeat(40))}`);
// Embedding
const embedPrice = breakdown.embed.cost != null
? `$${breakdown.embed.cost.toFixed(6)}`
: '?';
lines.push(` ${pc.dim('Embed query:')} ${breakdown.embed.tokens.toLocaleString()} tokens ${pc.dim(embedPrice)}`);
// Reranking
if (breakdown.rerank) {
const rerankPrice = breakdown.rerank.cost != null
? `$${breakdown.rerank.cost.toFixed(6)}`
: '?';
lines.push(` ${pc.dim('Rerank:')} ${breakdown.rerank.tokens.toLocaleString()} tokens ${pc.dim(rerankPrice)}`);
}
// LLM
const llmPrice = breakdown.llm.cost != null
? `$${breakdown.llm.cost.toFixed(6)}`
: (breakdown.llm.model === 'ollama' ? 'free' : '?');
lines.push(` ${pc.dim('LLM input:')} ${breakdown.llm.inputTokens.toLocaleString()} tokens`);
lines.push(` ${pc.dim('LLM output:')} ~${breakdown.llm.outputTokens.toLocaleString()} tokens ${pc.dim(llmPrice)}`);
// Total
lines.push(` ${pc.dim('─'.repeat(40))}`);
const totalStr = breakdown.totalCost < 0.001
? `< $0.001`
: `~$${breakdown.totalCost.toFixed(4)}`;
lines.push(` ${pc.bold('Total:')} ~${breakdown.totalTokens.toLocaleString()} tokens ${pc.cyan(totalStr)}`);
return lines.join('\n');
}
/**
* Show cost estimate and let user confirm or switch models interactively.
* Returns the chosen model name, or null if cancelled.
*
* @param {number} tokens - estimated token count
* @param {string} selectedModel - current model
* @param {object} [opts]
* @param {boolean} [opts.json] - if true, skip interactive and return selected
* @param {boolean} [opts.nonInteractive] - if true, just display and return selected
* @returns {Promise<string|null>} chosen model name, or null if cancelled
*/
async function confirmOrSwitchModel(tokens, selectedModel, opts = {}) {
const pc = require('picocolors');
const est = estimateCost(tokens, selectedModel);
// Display the comparison table
console.log('');
console.log(formatCostEstimate(est));
console.log('');
if (opts.json || opts.nonInteractive) {
return selectedModel;
}
// Build choices: proceed with current, switch to each alternative, cancel
const comparison = estimateCostComparison(tokens, selectedModel);
const p = require('@clack/prompts');
const options = [];
// Current model first
const currentRow = comparison.find(r => r.selected);
if (currentRow) {
const costStr = currentRow.cost < 0.001 ? '< $0.001' : `$${currentRow.cost.toFixed(4)}`;
options.push({
value: currentRow.model,
label: `Proceed with ${currentRow.model} (${costStr})`,
});
}
// Alternatives
for (const row of comparison) {
if (row.selected) continue;
const costStr = row.cost < 0.001 ? '< $0.001' : `$${row.cost.toFixed(4)}`;
options.push({
value: row.model,
label: `Switch to ${row.model} (${costStr})`,
hint: row.shortFor,
});
}
// Cancel
options.push({
value: '__cancel__',
label: pc.dim('Cancel'),
});
const choice = await p.select({
message: 'Choose a model',
options,
initialValue: selectedModel,
});
if (p.isCancel(choice) || choice === '__cancel__') {
p.cancel('Cancelled.');
return null;
}
return choice;
}
module.exports = {
estimateTokens,
estimateTokensForTexts,
getModelPrice,
estimateCost,
estimateCostComparison,
estimateChatCost,
estimateLLMCost,
formatCostEstimate,
formatChatCostBreakdown,
confirmOrSwitchModel,
};