voyageai-cli 1.13.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,209 @@
1
+ 'use strict';
2
+
3
+ const { MODEL_CATALOG } = require('../lib/catalog');
4
+ const ui = require('../lib/ui');
5
+
6
+ // Average tokens per document/query (rough industry estimates)
7
+ const DEFAULT_DOC_TOKENS = 500;
8
+ const DEFAULT_QUERY_TOKENS = 30;
9
+
10
+ /**
11
+ * Parse a shorthand number: "1M" → 1000000, "500K" → 500000, "1B" → 1000000000.
12
+ * @param {string} val
13
+ * @returns {number}
14
+ */
15
+ function parseShorthand(val) {
16
+ if (!val) return NaN;
17
+ const str = String(val).trim().toUpperCase();
18
+ const multipliers = { K: 1e3, M: 1e6, B: 1e9, T: 1e12 };
19
+ const match = str.match(/^([\d.]+)\s*([KMBT])?$/);
20
+ if (!match) return parseFloat(str);
21
+ const num = parseFloat(match[1]);
22
+ const suffix = match[2];
23
+ return suffix ? num * multipliers[suffix] : num;
24
+ }
25
+
26
+ /**
27
+ * Format a number with commas: 1234567 → "1,234,567".
28
+ */
29
+ function formatNum(n) {
30
+ return n.toLocaleString('en-US');
31
+ }
32
+
33
+ /**
34
+ * Format dollars: 0.50 → "$0.50", 1234.56 → "$1,234.56".
35
+ */
36
+ function formatDollars(n) {
37
+ if (n < 0.01 && n > 0) return `$${n.toFixed(4)}`;
38
+ if (n < 1) return `$${n.toFixed(2)}`;
39
+ return '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
40
+ }
41
+
42
+ /**
43
+ * Format a large number in short form: 1000000 → "1M".
44
+ */
45
+ function shortNum(n) {
46
+ if (n >= 1e9) return (n / 1e9).toFixed(n % 1e9 === 0 ? 0 : 1) + 'B';
47
+ if (n >= 1e6) return (n / 1e6).toFixed(n % 1e6 === 0 ? 0 : 1) + 'M';
48
+ if (n >= 1e3) return (n / 1e3).toFixed(n % 1e3 === 0 ? 0 : 1) + 'K';
49
+ return String(n);
50
+ }
51
+
52
+ /**
53
+ * Register the estimate command on a Commander program.
54
+ * @param {import('commander').Command} program
55
+ */
56
+ function registerEstimate(program) {
57
+ program
58
+ .command('estimate')
59
+ .description('Estimate embedding costs — symmetric vs asymmetric strategies')
60
+ .option('--docs <n>', 'Number of documents to embed (supports K/M/B shorthand)', '100K')
61
+ .option('--queries <n>', 'Number of queries per month (supports K/M/B shorthand)', '1M')
62
+ .option('--doc-tokens <n>', 'Average tokens per document', String(DEFAULT_DOC_TOKENS))
63
+ .option('--query-tokens <n>', 'Average tokens per query', String(DEFAULT_QUERY_TOKENS))
64
+ .option('--doc-model <model>', 'Model for document embedding (asymmetric)', 'voyage-4-large')
65
+ .option('--query-model <model>', 'Model for query embedding (asymmetric)', 'voyage-4-lite')
66
+ .option('--months <n>', 'Months to project', '12')
67
+ .option('--json', 'Machine-readable JSON output')
68
+ .option('-q, --quiet', 'Suppress non-essential output')
69
+ .action((opts) => {
70
+ const numDocs = parseShorthand(opts.docs);
71
+ const numQueries = parseShorthand(opts.queries);
72
+ const docTokens = parseInt(opts.docTokens, 10) || DEFAULT_DOC_TOKENS;
73
+ const queryTokens = parseInt(opts.queryTokens, 10) || DEFAULT_QUERY_TOKENS;
74
+ const months = parseInt(opts.months, 10) || 12;
75
+
76
+ if (isNaN(numDocs) || isNaN(numQueries)) {
77
+ console.error(ui.error('Invalid --docs or --queries value. Use numbers or shorthand (e.g., 1M, 500K).'));
78
+ process.exit(1);
79
+ }
80
+
81
+ // Get model prices
82
+ const v4Models = MODEL_CATALOG.filter(m => m.sharedSpace === 'voyage-4' && m.pricePerMToken != null);
83
+ const docModel = MODEL_CATALOG.find(m => m.name === opts.docModel);
84
+ const queryModel = MODEL_CATALOG.find(m => m.name === opts.queryModel);
85
+
86
+ if (!docModel || docModel.pricePerMToken == null) {
87
+ console.error(ui.error(`Unknown or unpriced model: ${opts.docModel}`));
88
+ process.exit(1);
89
+ }
90
+ if (!queryModel || queryModel.pricePerMToken == null) {
91
+ console.error(ui.error(`Unknown or unpriced model: ${opts.queryModel}`));
92
+ process.exit(1);
93
+ }
94
+
95
+ const docTotalTokens = numDocs * docTokens;
96
+ const queryTotalTokensPerMonth = numQueries * queryTokens;
97
+
98
+ // Calculate costs for different strategies
99
+ const strategies = [];
100
+
101
+ // Strategy 1: Symmetric with each V4 model
102
+ for (const model of v4Models) {
103
+ if (model.pricePerMToken === 0) continue; // skip free models for symmetric
104
+ const docCost = (docTotalTokens / 1e6) * model.pricePerMToken;
105
+ const queryCostPerMonth = (queryTotalTokensPerMonth / 1e6) * model.pricePerMToken;
106
+ const totalCost = docCost + (queryCostPerMonth * months);
107
+ strategies.push({
108
+ name: `Symmetric: ${model.name}`,
109
+ type: 'symmetric',
110
+ docModel: model.name,
111
+ queryModel: model.name,
112
+ docCost,
113
+ queryCostPerMonth,
114
+ totalCost,
115
+ months,
116
+ });
117
+ }
118
+
119
+ // Strategy 2: Asymmetric — user-specified doc+query combo
120
+ const asymDocCost = (docTotalTokens / 1e6) * docModel.pricePerMToken;
121
+ const asymQueryCostPerMonth = (queryTotalTokensPerMonth / 1e6) * queryModel.pricePerMToken;
122
+ const asymTotalCost = asymDocCost + (asymQueryCostPerMonth * months);
123
+ strategies.push({
124
+ name: `Asymmetric: ${docModel.name} docs + ${queryModel.name} queries`,
125
+ type: 'asymmetric',
126
+ docModel: docModel.name,
127
+ queryModel: queryModel.name,
128
+ docCost: asymDocCost,
129
+ queryCostPerMonth: asymQueryCostPerMonth,
130
+ totalCost: asymTotalCost,
131
+ months,
132
+ recommended: true,
133
+ });
134
+
135
+ // Strategy 3: Asymmetric with nano queries (if doc model isn't nano)
136
+ if (opts.queryModel !== 'voyage-4-nano') {
137
+ const nanoModel = MODEL_CATALOG.find(m => m.name === 'voyage-4-nano');
138
+ if (nanoModel) {
139
+ strategies.push({
140
+ name: `Asymmetric: ${docModel.name} docs + voyage-4-nano queries (local)`,
141
+ type: 'asymmetric-local',
142
+ docModel: docModel.name,
143
+ queryModel: 'voyage-4-nano',
144
+ docCost: asymDocCost,
145
+ queryCostPerMonth: 0,
146
+ totalCost: asymDocCost,
147
+ months,
148
+ });
149
+ }
150
+ }
151
+
152
+ // Sort by total cost
153
+ strategies.sort((a, b) => a.totalCost - b.totalCost);
154
+
155
+ if (opts.json) {
156
+ console.log(JSON.stringify({
157
+ params: { docs: numDocs, queries: numQueries, docTokens, queryTokens, months },
158
+ strategies,
159
+ }, null, 2));
160
+ return;
161
+ }
162
+
163
+ // Find the most expensive for savings comparison
164
+ const maxCost = Math.max(...strategies.map(s => s.totalCost));
165
+
166
+ if (!opts.quiet) {
167
+ console.log(ui.bold('💰 Voyage AI Cost Estimator'));
168
+ console.log('');
169
+ console.log(ui.label('Documents', `${shortNum(numDocs)} × ${formatNum(docTokens)} tokens = ${shortNum(docTotalTokens)} tokens (one-time)`));
170
+ console.log(ui.label('Queries', `${shortNum(numQueries)}/mo × ${formatNum(queryTokens)} tokens = ${shortNum(queryTotalTokensPerMonth)} tokens/mo`));
171
+ console.log(ui.label('Projection', `${months} months`));
172
+ console.log('');
173
+ }
174
+
175
+ console.log(ui.bold('Strategy Comparison:'));
176
+ console.log('');
177
+
178
+ for (const s of strategies) {
179
+ const savings = maxCost > 0 ? ((1 - s.totalCost / maxCost) * 100) : 0;
180
+ const savingsStr = savings > 0 ? ui.green(` (${savings.toFixed(0)}% savings)`) : '';
181
+ const marker = s.recommended ? ui.cyan(' ★ recommended') : '';
182
+ const localNote = s.type === 'asymmetric-local' ? ui.dim(' (query cost = $0, runs locally)') : '';
183
+
184
+ console.log(` ${s.recommended ? ui.cyan('►') : ' '} ${ui.bold(s.name)}${marker}`);
185
+ console.log(` Doc embedding: ${formatDollars(s.docCost)} ${ui.dim('(one-time)')}`);
186
+ console.log(` Query cost: ${formatDollars(s.queryCostPerMonth)}/mo${localNote}`);
187
+ console.log(` ${months}-mo total: ${ui.bold(formatDollars(s.totalCost))}${savingsStr}`);
188
+ console.log('');
189
+ }
190
+
191
+ // Show the asymmetric advantage
192
+ const symmetricLarge = strategies.find(s => s.type === 'symmetric' && s.docModel === 'voyage-4-large');
193
+ const asymmetric = strategies.find(s => s.recommended);
194
+ if (symmetricLarge && asymmetric && symmetricLarge.totalCost > asymmetric.totalCost) {
195
+ const saved = symmetricLarge.totalCost - asymmetric.totalCost;
196
+ const pct = ((saved / symmetricLarge.totalCost) * 100).toFixed(0);
197
+ console.log(ui.success(`Asymmetric retrieval saves ${formatDollars(saved)} (${pct}%) over symmetric voyage-4-large`));
198
+ console.log(ui.dim(' Same document quality — lower query costs. Shared embedding space makes this possible.'));
199
+ console.log('');
200
+ }
201
+
202
+ if (!opts.quiet) {
203
+ console.log(ui.dim('Tip: Use --doc-model and --query-model to compare any combination.'));
204
+ console.log(ui.dim(' Use "vai explain shared-space" to learn about asymmetric retrieval.'));
205
+ }
206
+ });
207
+ }
208
+
209
+ module.exports = { registerEstimate };
@@ -0,0 +1,153 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const readline = require('readline');
6
+ const { MODEL_CATALOG } = require('../lib/catalog');
7
+ const { STRATEGIES } = require('../lib/chunker');
8
+ const { defaultProjectConfig, saveProject, findProjectFile, PROJECT_FILE } = require('../lib/project');
9
+ const ui = require('../lib/ui');
10
+
11
+ /**
12
+ * Prompt the user for input with a default value.
13
+ * @param {readline.Interface} rl
14
+ * @param {string} question
15
+ * @param {string} [defaultVal]
16
+ * @returns {Promise<string>}
17
+ */
18
+ function ask(rl, question, defaultVal) {
19
+ const suffix = defaultVal ? ` ${ui.dim(`(${defaultVal})`)}` : '';
20
+ return new Promise((resolve) => {
21
+ rl.question(` ${question}${suffix}: `, (answer) => {
22
+ resolve(answer.trim() || defaultVal || '');
23
+ });
24
+ });
25
+ }
26
+
27
+ /**
28
+ * Prompt for a choice from a list.
29
+ * @param {readline.Interface} rl
30
+ * @param {string} question
31
+ * @param {string[]} choices
32
+ * @param {string} defaultVal
33
+ * @returns {Promise<string>}
34
+ */
35
+ async function askChoice(rl, question, choices, defaultVal) {
36
+ console.log('');
37
+ for (let i = 0; i < choices.length; i++) {
38
+ const marker = choices[i] === defaultVal ? ui.cyan('→') : ' ';
39
+ console.log(` ${marker} ${i + 1}. ${choices[i]}`);
40
+ }
41
+ const answer = await ask(rl, question, defaultVal);
42
+ // Accept number or value
43
+ const num = parseInt(answer, 10);
44
+ if (num >= 1 && num <= choices.length) return choices[num - 1];
45
+ if (choices.includes(answer)) return answer;
46
+ return defaultVal;
47
+ }
48
+
49
+ /**
50
+ * Register the init command on a Commander program.
51
+ * @param {import('commander').Command} program
52
+ */
53
+ function registerInit(program) {
54
+ program
55
+ .command('init')
56
+ .description('Initialize a project with .vai.json configuration')
57
+ .option('-y, --yes', 'Accept all defaults (non-interactive)')
58
+ .option('--force', 'Overwrite existing .vai.json')
59
+ .option('--json', 'Output created config as JSON (non-interactive)')
60
+ .option('-q, --quiet', 'Suppress non-essential output')
61
+ .action(async (opts) => {
62
+ // Check for existing config
63
+ const existing = findProjectFile();
64
+ if (existing && !opts.force) {
65
+ const relPath = path.relative(process.cwd(), existing);
66
+ console.error(ui.warn(`Project already initialized: ${relPath}`));
67
+ console.error(ui.dim(' Use --force to overwrite.'));
68
+ process.exit(1);
69
+ }
70
+
71
+ const defaults = defaultProjectConfig();
72
+
73
+ // Non-interactive mode
74
+ if (opts.yes || opts.json) {
75
+ const filePath = saveProject(defaults);
76
+ if (opts.json) {
77
+ console.log(JSON.stringify(defaults, null, 2));
78
+ } else if (!opts.quiet) {
79
+ console.log(ui.success(`Created ${PROJECT_FILE}`));
80
+ }
81
+ return;
82
+ }
83
+
84
+ // Interactive mode
85
+ console.log('');
86
+ console.log(ui.bold(' 🚀 Initialize Voyage AI Project'));
87
+ console.log(ui.dim(' Creates .vai.json in the current directory.'));
88
+ console.log(ui.dim(' Press Enter to accept defaults.'));
89
+ console.log('');
90
+
91
+ const rl = readline.createInterface({
92
+ input: process.stdin,
93
+ output: process.stdout,
94
+ });
95
+
96
+ try {
97
+ // Embedding model
98
+ const embeddingModels = MODEL_CATALOG
99
+ .filter(m => m.type === 'embedding' && !m.legacy && !m.unreleased)
100
+ .map(m => m.name);
101
+ const model = await askChoice(rl, 'Embedding model', embeddingModels, defaults.model);
102
+
103
+ // MongoDB settings
104
+ console.log('');
105
+ console.log(ui.bold(' MongoDB Atlas'));
106
+ const db = await ask(rl, 'Database name', defaults.db || 'myapp');
107
+ const collection = await ask(rl, 'Collection name', defaults.collection || 'documents');
108
+ const field = await ask(rl, 'Embedding field', defaults.field);
109
+ const index = await ask(rl, 'Vector index name', defaults.index);
110
+
111
+ // Dimensions
112
+ const modelInfo = MODEL_CATALOG.find(m => m.name === model);
113
+ const defaultDims = modelInfo && modelInfo.dimensions.includes('1024') ? '1024' : '512';
114
+ const dimensions = parseInt(await ask(rl, 'Dimensions', defaultDims), 10) || parseInt(defaultDims, 10);
115
+
116
+ // Chunking
117
+ console.log('');
118
+ console.log(ui.bold(' Chunking'));
119
+ const strategy = await askChoice(rl, 'Chunk strategy', STRATEGIES, defaults.chunk.strategy);
120
+ const chunkSize = parseInt(await ask(rl, 'Chunk size (chars)', String(defaults.chunk.size)), 10);
121
+ const chunkOverlap = parseInt(await ask(rl, 'Chunk overlap (chars)', String(defaults.chunk.overlap)), 10);
122
+
123
+ const config = {
124
+ model,
125
+ db,
126
+ collection,
127
+ field,
128
+ inputType: 'document',
129
+ dimensions,
130
+ index,
131
+ chunk: {
132
+ strategy,
133
+ size: chunkSize,
134
+ overlap: chunkOverlap,
135
+ },
136
+ };
137
+
138
+ const filePath = saveProject(config);
139
+ console.log('');
140
+ console.log(ui.success(`Created ${path.relative(process.cwd(), filePath)}`));
141
+ console.log('');
142
+ console.log(ui.dim(' Next steps:'));
143
+ console.log(ui.dim(' vai chunk ./docs/ # Chunk your documents'));
144
+ console.log(ui.dim(' vai pipeline ./docs/ # Chunk → embed → store (coming soon)'));
145
+ console.log(ui.dim(' vai search --query "..." # Search your collection'));
146
+ console.log('');
147
+ } finally {
148
+ rl.close();
149
+ }
150
+ });
151
+ }
152
+
153
+ module.exports = { registerInit };
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { MODEL_CATALOG } = require('../lib/catalog');
3
+ const { MODEL_CATALOG, BENCHMARK_SCORES } = require('../lib/catalog');
4
4
  const { getApiBase } = require('../lib/api');
5
5
  const { formatTable } = require('../lib/format');
6
6
  const ui = require('../lib/ui');
@@ -42,6 +42,7 @@ function registerModels(program) {
42
42
  .option('-t, --type <type>', 'Filter by type: embedding, reranking, or all', 'all')
43
43
  .option('-a, --all', 'Show all models including legacy')
44
44
  .option('-w, --wide', 'Wide output (show all columns untruncated)')
45
+ .option('-b, --benchmarks', 'Show RTEB benchmark scores')
45
46
  .option('--json', 'Machine-readable JSON output')
46
47
  .option('-q, --quiet', 'Suppress non-essential output')
47
48
  .action((opts) => {
@@ -86,7 +87,9 @@ function registerModels(program) {
86
87
  const name = ui.cyan(m.name);
87
88
  const type = m.type.startsWith('embedding') ? ui.green(m.type) : ui.yellow(m.type);
88
89
  const price = ui.dim(m.price);
89
- return [name, type, m.context, m.dimensions, price, m.bestFor];
90
+ const arch = m.architecture ? (m.architecture === 'moe' ? ui.cyan('MoE') : m.architecture) : '—';
91
+ const space = m.sharedSpace ? ui.green('✓ ' + m.sharedSpace) : '—';
92
+ return [name, type, m.context, m.dimensions, arch, space, price, m.bestFor];
90
93
  };
91
94
 
92
95
  const formatCompactRow = (m) => {
@@ -98,7 +101,7 @@ function registerModels(program) {
98
101
  };
99
102
 
100
103
  if (opts.wide) {
101
- const headers = ['Model', 'Type', 'Context', 'Dimensions', 'Price', 'Best For'];
104
+ const headers = ['Model', 'Type', 'Context', 'Dimensions', 'Arch', 'Space', 'Price', 'Best For'];
102
105
  const boldHeaders = headers.map(h => ui.bold(h));
103
106
  const rows = displayCurrent.map(formatWideRow);
104
107
  console.log(formatTable(boldHeaders, rows));
@@ -123,6 +126,29 @@ function registerModels(program) {
123
126
  }
124
127
  }
125
128
 
129
+ // Show benchmark scores if requested
130
+ if (opts.benchmarks) {
131
+ console.log('');
132
+ console.log(ui.bold('RTEB Benchmark Scores (NDCG@10, avg 29 datasets)'));
133
+ console.log(ui.dim('Source: Voyage AI, January 2026'));
134
+ console.log('');
135
+
136
+ const maxScore = Math.max(...BENCHMARK_SCORES.map(b => b.score));
137
+ const barWidth = 30;
138
+
139
+ for (const b of BENCHMARK_SCORES) {
140
+ const barLen = Math.round((b.score / maxScore) * barWidth);
141
+ const bar = '█'.repeat(barLen) + '░'.repeat(barWidth - barLen);
142
+ const isVoyage = b.provider === 'Voyage AI';
143
+ const name = isVoyage ? ui.cyan(b.model.padEnd(22)) : ui.dim(b.model.padEnd(22));
144
+ const score = isVoyage ? ui.bold(b.score.toFixed(2)) : b.score.toFixed(2);
145
+ const colorBar = isVoyage ? ui.cyan(bar) : ui.dim(bar);
146
+ console.log(` ${name} ${colorBar} ${score}`);
147
+ }
148
+ console.log('');
149
+ console.log(ui.dim(' Run "vai explain rteb" for details.'));
150
+ }
151
+
126
152
  if (!opts.quiet) {
127
153
  console.log('');
128
154
  if (!opts.wide) {
@@ -130,7 +156,9 @@ function registerModels(program) {
130
156
  }
131
157
  console.log(ui.dim('Free tier: 200M tokens (most models), 50M (domain-specific)'));
132
158
  console.log(ui.dim('All 4-series models share the same embedding space.'));
133
- if (!opts.wide) {
159
+ if (!opts.wide && !opts.benchmarks) {
160
+ console.log(ui.dim('Use --wide for full details, --benchmarks for RTEB scores.'));
161
+ } else if (!opts.wide) {
134
162
  console.log(ui.dim('Use --wide for full details.'));
135
163
  }
136
164
  }
@@ -24,29 +24,51 @@ function getDefaultDimensions() {
24
24
 
25
25
  // The model catalog: like a wine list (I don't drink :-P), except every choice
26
26
  // leads to vectors instead of regret.
27
- /** @type {Array<{name: string, type: string, context: string, dimensions: string, price: string, bestFor: string}>} */
27
+ /** @type {Array<{name: string, type: string, context: string, dimensions: string, price: string, bestFor: string, family?: string, architecture?: string, sharedSpace?: string, huggingface?: string, pricePerMToken?: number, rtebScore?: number}>} */
28
28
  const MODEL_CATALOG = [
29
- { name: 'voyage-4-large', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.12/1M tokens', bestFor: 'Best quality, multilingual', shortFor: 'Best quality' },
30
- { name: 'voyage-4', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.06/1M tokens', bestFor: 'Balanced quality/perf', shortFor: 'Balanced' },
31
- { name: 'voyage-4-lite', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.02/1M tokens', bestFor: 'Lowest cost', shortFor: 'Budget' },
32
- { name: 'voyage-code-3', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.18/1M tokens', bestFor: 'Code retrieval', shortFor: 'Code' },
33
- { name: 'voyage-finance-2', type: 'embedding', context: '32K', dimensions: '1024', price: '$0.12/1M tokens', bestFor: 'Finance', shortFor: 'Finance' },
34
- { name: 'voyage-law-2', type: 'embedding', context: '16K', dimensions: '1024', price: '$0.12/1M tokens', bestFor: 'Legal', shortFor: 'Legal' },
35
- { name: 'voyage-context-3', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.18/1M tokens', bestFor: 'Contextualized chunks', shortFor: 'Context chunks', unreleased: true },
29
+ { name: 'voyage-4-large', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.12/1M tokens', pricePerMToken: 0.12, bestFor: 'Best quality, multilingual, MoE', shortFor: 'Best quality', family: 'voyage-4', architecture: 'moe', sharedSpace: 'voyage-4', rtebScore: 71.41 },
30
+ { name: 'voyage-4', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.06/1M tokens', pricePerMToken: 0.06, bestFor: 'Balanced quality/perf', shortFor: 'Balanced', family: 'voyage-4', architecture: 'dense', sharedSpace: 'voyage-4', rtebScore: 70.07 },
31
+ { name: 'voyage-4-lite', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.02/1M tokens', pricePerMToken: 0.02, bestFor: 'Lowest cost', shortFor: 'Budget', family: 'voyage-4', architecture: 'dense', sharedSpace: 'voyage-4', rtebScore: 68.10 },
32
+ { name: 'voyage-code-3', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.18/1M tokens', pricePerMToken: 0.18, bestFor: 'Code retrieval', shortFor: 'Code' },
33
+ { name: 'voyage-finance-2', type: 'embedding', context: '32K', dimensions: '1024', price: '$0.12/1M tokens', pricePerMToken: 0.12, bestFor: 'Finance', shortFor: 'Finance' },
34
+ { name: 'voyage-law-2', type: 'embedding', context: '16K', dimensions: '1024', price: '$0.12/1M tokens', pricePerMToken: 0.12, bestFor: 'Legal', shortFor: 'Legal' },
35
+ { name: 'voyage-context-3', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.18/1M tokens', pricePerMToken: 0.18, bestFor: 'Contextualized chunks', shortFor: 'Context chunks', unreleased: true },
36
36
  { name: 'voyage-multimodal-3.5', type: 'embedding-multimodal', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.12/M + $0.60/B px', bestFor: 'Text + images + video', shortFor: 'Multimodal', multimodal: true },
37
- { name: 'rerank-2.5', type: 'reranking', context: '32K', dimensions: '—', price: '$0.05/1M tokens', bestFor: 'Best quality reranking', shortFor: 'Best reranker' },
38
- { name: 'rerank-2.5-lite', type: 'reranking', context: '32K', dimensions: '—', price: '$0.02/1M tokens', bestFor: 'Fast reranking', shortFor: 'Fast reranker' },
39
- { name: 'voyage-4-nano', type: 'embedding', context: '32K', dimensions: '512 (default), 128, 256', price: 'Open-weight', bestFor: 'Open-weight / edge', shortFor: 'Open / edge', local: true },
37
+ { name: 'rerank-2.5', type: 'reranking', context: '32K', dimensions: '—', price: '$0.05/1M tokens', pricePerMToken: 0.05, bestFor: 'Best quality reranking', shortFor: 'Best reranker' },
38
+ { name: 'rerank-2.5-lite', type: 'reranking', context: '32K', dimensions: '—', price: '$0.02/1M tokens', pricePerMToken: 0.02, bestFor: 'Fast reranking', shortFor: 'Fast reranker' },
39
+ { name: 'voyage-4-nano', type: 'embedding', context: '32K', dimensions: '512 (default), 128, 256', price: 'Open-weight (free)', pricePerMToken: 0, bestFor: 'Open-weight / edge / local', shortFor: 'Open / edge', local: true, family: 'voyage-4', architecture: 'dense', sharedSpace: 'voyage-4', huggingface: 'https://huggingface.co/voyageai/voyage-4-nano', rtebScore: null },
40
40
  // Legacy models
41
- { name: 'voyage-3-large', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.18/1M tokens', bestFor: 'Previous gen quality', shortFor: 'Previous gen quality', legacy: true },
42
- { name: 'voyage-3.5', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.06/1M tokens', bestFor: 'Previous gen balanced', shortFor: 'Previous gen balanced', legacy: true },
43
- { name: 'voyage-3.5-lite', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.02/1M tokens', bestFor: 'Previous gen budget', shortFor: 'Previous gen budget', legacy: true },
44
- { name: 'voyage-code-2', type: 'embedding', context: '16K', dimensions: '1536', price: '$0.12/1M tokens', bestFor: 'Legacy code', shortFor: 'Legacy code', legacy: true },
45
- { name: 'voyage-multimodal-3', type: 'embedding-multimodal', context: '32K', dimensions: '1024', price: '$0.12/1M tokens', bestFor: 'Legacy multimodal', shortFor: 'Legacy multimodal', legacy: true, multimodal: true },
46
- { name: 'rerank-2', type: 'reranking', context: '16K', dimensions: '—', price: '$0.05/1M tokens', bestFor: 'Legacy reranker', shortFor: 'Legacy reranker', legacy: true },
47
- { name: 'rerank-2-lite', type: 'reranking', context: '8K', dimensions: '—', price: '$0.02/1M tokens', bestFor: 'Legacy fast reranker', shortFor: 'Legacy fast reranker', legacy: true },
41
+ { name: 'voyage-3-large', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.18/1M tokens', pricePerMToken: 0.18, bestFor: 'Previous gen quality', shortFor: 'Previous gen quality', legacy: true, rtebScore: null },
42
+ { name: 'voyage-3.5', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.06/1M tokens', pricePerMToken: 0.06, bestFor: 'Previous gen balanced', shortFor: 'Previous gen balanced', legacy: true, rtebScore: null },
43
+ { name: 'voyage-3.5-lite', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.02/1M tokens', pricePerMToken: 0.02, bestFor: 'Previous gen budget', shortFor: 'Previous gen budget', legacy: true, rtebScore: null },
44
+ { name: 'voyage-code-2', type: 'embedding', context: '16K', dimensions: '1536', price: '$0.12/1M tokens', pricePerMToken: 0.12, bestFor: 'Legacy code', shortFor: 'Legacy code', legacy: true },
45
+ { name: 'voyage-multimodal-3', type: 'embedding-multimodal', context: '32K', dimensions: '1024', price: '$0.12/1M tokens', pricePerMToken: 0.12, bestFor: 'Legacy multimodal', shortFor: 'Legacy multimodal', legacy: true, multimodal: true },
46
+ { name: 'rerank-2', type: 'reranking', context: '16K', dimensions: '—', price: '$0.05/1M tokens', pricePerMToken: 0.05, bestFor: 'Legacy reranker', shortFor: 'Legacy reranker', legacy: true },
47
+ { name: 'rerank-2-lite', type: 'reranking', context: '8K', dimensions: '—', price: '$0.02/1M tokens', pricePerMToken: 0.02, bestFor: 'Legacy fast reranker', shortFor: 'Legacy fast reranker', legacy: true },
48
48
  ];
49
49
 
50
+ /**
51
+ * RTEB benchmark scores for competitive models (NDCG@10 average across 29 datasets).
52
+ * Source: Voyage AI blog, January 15 2026.
53
+ */
54
+ const BENCHMARK_SCORES = [
55
+ { model: 'voyage-4-large', provider: 'Voyage AI', score: 71.41 },
56
+ { model: 'voyage-4', provider: 'Voyage AI', score: 70.07 },
57
+ { model: 'voyage-4-lite', provider: 'Voyage AI', score: 68.10 },
58
+ { model: 'Gemini Embedding 001', provider: 'Google', score: 68.66 },
59
+ { model: 'Cohere Embed v4', provider: 'Cohere', score: 65.75 },
60
+ { model: 'OpenAI v3 Large', provider: 'OpenAI', score: 62.57 },
61
+ ];
62
+
63
+ /**
64
+ * Get models that share an embedding space.
65
+ * @param {string} space - e.g. 'voyage-4'
66
+ * @returns {Array}
67
+ */
68
+ function getSharedSpaceModels(space) {
69
+ return MODEL_CATALOG.filter(m => m.sharedSpace === space);
70
+ }
71
+
50
72
  module.exports = {
51
73
  DEFAULT_EMBED_MODEL,
52
74
  DEFAULT_RERANK_MODEL,
@@ -54,4 +76,6 @@ module.exports = {
54
76
  getDefaultModel,
55
77
  getDefaultDimensions,
56
78
  MODEL_CATALOG,
79
+ BENCHMARK_SCORES,
80
+ getSharedSpaceModels,
57
81
  };