voyageai-cli 1.6.1 ā 1.8.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.
- package/README.md +4 -3
- package/package.json +2 -3
- package/src/cli.js +4 -0
- package/src/commands/benchmark.js +799 -0
- package/src/commands/playground.js +236 -0
- package/src/lib/explanations.js +47 -0
- package/src/lib/ui.js +53 -4
- package/src/playground/index.html +1111 -0
- package/test/commands/benchmark.test.js +252 -0
- package/test/commands/playground.test.js +137 -0
- package/test/lib/explanations.test.js +1 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { exec } = require('child_process');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Register the playground command on a Commander program.
|
|
10
|
+
* @param {import('commander').Command} program
|
|
11
|
+
*/
|
|
12
|
+
function registerPlayground(program) {
|
|
13
|
+
program
|
|
14
|
+
.command('playground')
|
|
15
|
+
.description('Launch interactive web playground for Voyage AI')
|
|
16
|
+
.option('-p, --port <port>', 'Port to serve on', '3333')
|
|
17
|
+
.option('--no-open', 'Skip auto-opening browser')
|
|
18
|
+
.action(async (opts) => {
|
|
19
|
+
const port = parseInt(opts.port, 10) || 3333;
|
|
20
|
+
const server = createPlaygroundServer();
|
|
21
|
+
|
|
22
|
+
server.listen(port, () => {
|
|
23
|
+
const url = `http://localhost:${port}`;
|
|
24
|
+
console.log(`š§ Playground running at ${url} ā Press Ctrl+C to stop`);
|
|
25
|
+
|
|
26
|
+
if (opts.open !== false) {
|
|
27
|
+
openBrowser(url);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
server.on('error', (err) => {
|
|
32
|
+
if (err.code === 'EADDRINUSE') {
|
|
33
|
+
console.error(`Error: Port ${port} is already in use. Try --port <other-port>`);
|
|
34
|
+
} else {
|
|
35
|
+
console.error(`Server error: ${err.message}`);
|
|
36
|
+
}
|
|
37
|
+
process.exit(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Graceful shutdown
|
|
41
|
+
const shutdown = () => {
|
|
42
|
+
console.log('\nš§ Playground stopped.');
|
|
43
|
+
server.close(() => process.exit(0));
|
|
44
|
+
// Force exit after 2s if connections linger
|
|
45
|
+
setTimeout(() => process.exit(0), 2000);
|
|
46
|
+
};
|
|
47
|
+
process.on('SIGINT', shutdown);
|
|
48
|
+
process.on('SIGTERM', shutdown);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create the playground HTTP server (exported for testing).
|
|
54
|
+
* @returns {http.Server}
|
|
55
|
+
*/
|
|
56
|
+
function createPlaygroundServer() {
|
|
57
|
+
const { getApiBase, requireApiKey, generateEmbeddings } = require('../lib/api');
|
|
58
|
+
const { MODEL_CATALOG } = require('../lib/catalog');
|
|
59
|
+
const { cosineSimilarity } = require('../lib/math');
|
|
60
|
+
const { getConfigValue } = require('../lib/config');
|
|
61
|
+
|
|
62
|
+
const htmlPath = path.join(__dirname, '..', 'playground', 'index.html');
|
|
63
|
+
|
|
64
|
+
const server = http.createServer(async (req, res) => {
|
|
65
|
+
// CORS headers for local dev
|
|
66
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
67
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
68
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
69
|
+
|
|
70
|
+
if (req.method === 'OPTIONS') {
|
|
71
|
+
res.writeHead(204);
|
|
72
|
+
res.end();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
// Serve HTML
|
|
78
|
+
if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
|
|
79
|
+
const html = fs.readFileSync(htmlPath, 'utf8');
|
|
80
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
81
|
+
res.end(html);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// API: Models
|
|
86
|
+
if (req.method === 'GET' && req.url === '/api/models') {
|
|
87
|
+
const models = MODEL_CATALOG.filter(m => !m.legacy);
|
|
88
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
89
|
+
res.end(JSON.stringify({ models }));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// API: Config
|
|
94
|
+
if (req.method === 'GET' && req.url === '/api/config') {
|
|
95
|
+
const key = process.env.VOYAGE_API_KEY || getConfigValue('apiKey');
|
|
96
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
97
|
+
res.end(JSON.stringify({
|
|
98
|
+
baseUrl: getApiBase(),
|
|
99
|
+
hasKey: !!key,
|
|
100
|
+
}));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Parse JSON body for POST routes
|
|
105
|
+
if (req.method === 'POST') {
|
|
106
|
+
const body = await readBody(req);
|
|
107
|
+
let parsed;
|
|
108
|
+
try {
|
|
109
|
+
parsed = JSON.parse(body);
|
|
110
|
+
} catch {
|
|
111
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
112
|
+
res.end(JSON.stringify({ error: 'Invalid JSON body' }));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// API: Embed
|
|
117
|
+
if (req.url === '/api/embed') {
|
|
118
|
+
const { texts, model, inputType, dimensions } = parsed;
|
|
119
|
+
if (!texts || !Array.isArray(texts) || texts.length === 0) {
|
|
120
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
121
|
+
res.end(JSON.stringify({ error: 'texts must be a non-empty array' }));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const result = await generateEmbeddings(texts, {
|
|
125
|
+
model: model || undefined,
|
|
126
|
+
inputType: inputType || undefined,
|
|
127
|
+
dimensions: dimensions || undefined,
|
|
128
|
+
});
|
|
129
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
130
|
+
res.end(JSON.stringify(result));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// API: Rerank
|
|
135
|
+
if (req.url === '/api/rerank') {
|
|
136
|
+
const { query, documents, model, topK } = parsed;
|
|
137
|
+
if (!query || !documents || !Array.isArray(documents)) {
|
|
138
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
139
|
+
res.end(JSON.stringify({ error: 'query and documents are required' }));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const { apiRequest } = require('../lib/api');
|
|
143
|
+
const rerankBody = {
|
|
144
|
+
query,
|
|
145
|
+
documents,
|
|
146
|
+
model: model || 'rerank-2.5',
|
|
147
|
+
};
|
|
148
|
+
if (topK) rerankBody.top_k = topK;
|
|
149
|
+
const result = await apiRequest('/rerank', rerankBody);
|
|
150
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
151
|
+
res.end(JSON.stringify(result));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// API: Similarity
|
|
156
|
+
if (req.url === '/api/similarity') {
|
|
157
|
+
const { texts, model } = parsed;
|
|
158
|
+
if (!texts || !Array.isArray(texts) || texts.length < 2) {
|
|
159
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
160
|
+
res.end(JSON.stringify({ error: 'texts must be an array with at least 2 items' }));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const result = await generateEmbeddings(texts, { model: model || undefined });
|
|
164
|
+
const embeddings = result.data.map(d => d.embedding);
|
|
165
|
+
|
|
166
|
+
// Compute pairwise similarity matrix
|
|
167
|
+
const matrix = [];
|
|
168
|
+
for (let i = 0; i < embeddings.length; i++) {
|
|
169
|
+
const row = [];
|
|
170
|
+
for (let j = 0; j < embeddings.length; j++) {
|
|
171
|
+
row.push(i === j ? 1.0 : cosineSimilarity(embeddings[i], embeddings[j]));
|
|
172
|
+
}
|
|
173
|
+
matrix.push(row);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
177
|
+
res.end(JSON.stringify({
|
|
178
|
+
matrix,
|
|
179
|
+
embeddings: result.data,
|
|
180
|
+
usage: result.usage,
|
|
181
|
+
model: result.model,
|
|
182
|
+
}));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 404
|
|
188
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
189
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
190
|
+
|
|
191
|
+
} catch (err) {
|
|
192
|
+
// Catch API errors that call process.exit ā we override for playground
|
|
193
|
+
console.error(`Playground API error: ${err.message}`);
|
|
194
|
+
if (!res.headersSent) {
|
|
195
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
196
|
+
res.end(JSON.stringify({ error: err.message || 'Internal server error' }));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return server;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Read the full request body as a string.
|
|
206
|
+
* @param {http.IncomingMessage} req
|
|
207
|
+
* @returns {Promise<string>}
|
|
208
|
+
*/
|
|
209
|
+
function readBody(req) {
|
|
210
|
+
return new Promise((resolve, reject) => {
|
|
211
|
+
const chunks = [];
|
|
212
|
+
req.on('data', chunk => chunks.push(chunk));
|
|
213
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
|
|
214
|
+
req.on('error', reject);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Open a URL in the default browser (cross-platform).
|
|
220
|
+
* @param {string} url
|
|
221
|
+
*/
|
|
222
|
+
function openBrowser(url) {
|
|
223
|
+
const platform = process.platform;
|
|
224
|
+
let cmd;
|
|
225
|
+
if (platform === 'darwin') cmd = `open "${url}"`;
|
|
226
|
+
else if (platform === 'win32') cmd = `start "${url}"`;
|
|
227
|
+
else cmd = `xdg-open "${url}"`;
|
|
228
|
+
|
|
229
|
+
exec(cmd, (err) => {
|
|
230
|
+
if (err) {
|
|
231
|
+
console.log(`Could not auto-open browser. Visit: ${url}`);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = { registerPlayground, createPlaygroundServer };
|
package/src/lib/explanations.js
CHANGED
|
@@ -406,6 +406,48 @@ const concepts = {
|
|
|
406
406
|
'vai embed --file document.txt --input-type document',
|
|
407
407
|
],
|
|
408
408
|
},
|
|
409
|
+
benchmarking: {
|
|
410
|
+
title: 'Benchmarking & Model Selection',
|
|
411
|
+
summary: 'How to choose the right model for your use case',
|
|
412
|
+
content: [
|
|
413
|
+
`Choosing the right embedding or reranking model depends on your priorities:`,
|
|
414
|
+
`${pc.cyan('latency')}, ${pc.cyan('accuracy')}, ${pc.cyan('cost')}, or a balance of all three.`,
|
|
415
|
+
``,
|
|
416
|
+
`${pc.bold('vai benchmark embed')} ā Compare embedding models head-to-head:`,
|
|
417
|
+
` Measures avg/p50/p95 latency, token usage, and cost per model.`,
|
|
418
|
+
` ${pc.dim('vai benchmark embed --models voyage-4-large,voyage-4,voyage-4-lite --rounds 5')}`,
|
|
419
|
+
``,
|
|
420
|
+
`${pc.bold('vai benchmark similarity')} ā Test ranking quality on your data:`,
|
|
421
|
+
` Embeds a query + corpus with each model, shows side-by-side top-K rankings.`,
|
|
422
|
+
` If models agree on the top results, the cheaper one is likely sufficient.`,
|
|
423
|
+
` ${pc.dim('vai benchmark similarity --query "your query" --file corpus.txt')}`,
|
|
424
|
+
``,
|
|
425
|
+
`${pc.bold('vai benchmark rerank')} ā Compare reranking models:`,
|
|
426
|
+
` Measures latency and shows how models order the same documents.`,
|
|
427
|
+
` ${pc.dim('vai benchmark rerank --query "your query" --documents-file docs.json')}`,
|
|
428
|
+
``,
|
|
429
|
+
`${pc.bold('vai benchmark cost')} ā Project monthly costs at scale:`,
|
|
430
|
+
` Shows estimated cost for each model at different daily query volumes.`,
|
|
431
|
+
` ${pc.dim('vai benchmark cost --tokens 500 --volumes 100,1000,10000,100000')}`,
|
|
432
|
+
``,
|
|
433
|
+
`${pc.bold('vai benchmark batch')} ā Find optimal batch size for ingestion:`,
|
|
434
|
+
` Measures throughput (texts/sec) at different batch sizes.`,
|
|
435
|
+
` ${pc.dim('vai benchmark batch --batch-sizes 1,5,10,25,50 --rounds 3')}`,
|
|
436
|
+
``,
|
|
437
|
+
`${pc.bold('Decision framework:')}`,
|
|
438
|
+
` 1. Run ${pc.cyan('benchmark cost')} to eliminate models outside your budget`,
|
|
439
|
+
` 2. Run ${pc.cyan('benchmark embed')} to compare latency of affordable models`,
|
|
440
|
+
` 3. Run ${pc.cyan('benchmark similarity')} with your actual data to compare quality`,
|
|
441
|
+
` 4. If quality is similar, pick the cheaper/faster model`,
|
|
442
|
+
` 5. Use ${pc.cyan('--save')} to track results over time as your data evolves`,
|
|
443
|
+
].join('\n'),
|
|
444
|
+
links: ['https://www.mongodb.com/docs/voyageai/models/text-embeddings/'],
|
|
445
|
+
tryIt: [
|
|
446
|
+
'vai benchmark embed --rounds 3',
|
|
447
|
+
'vai benchmark cost',
|
|
448
|
+
'vai benchmark similarity --query "your search query" --file your-docs.txt',
|
|
449
|
+
],
|
|
450
|
+
},
|
|
409
451
|
};
|
|
410
452
|
|
|
411
453
|
/**
|
|
@@ -446,6 +488,11 @@ const aliases = {
|
|
|
446
488
|
batch: 'batch-processing',
|
|
447
489
|
'batch-processing': 'batch-processing',
|
|
448
490
|
batching: 'batch-processing',
|
|
491
|
+
benchmark: 'benchmarking',
|
|
492
|
+
benchmarking: 'benchmarking',
|
|
493
|
+
'model-selection': 'benchmarking',
|
|
494
|
+
choosing: 'benchmarking',
|
|
495
|
+
compare: 'benchmarking',
|
|
449
496
|
};
|
|
450
497
|
|
|
451
498
|
/**
|
package/src/lib/ui.js
CHANGED
|
@@ -1,8 +1,32 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const pc = require('picocolors');
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
|
|
5
|
+
// ora v9 is ESM-only. Use dynamic import with a sync fallback for environments
|
|
6
|
+
// that don't support top-level require() of ESM (Node 18).
|
|
7
|
+
let _ora = null;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get the ora spinner function. Lazy-loaded via dynamic import.
|
|
11
|
+
* Returns a no-op fallback if ora can't be loaded (Node 18 CJS compat).
|
|
12
|
+
* @returns {Promise<Function>}
|
|
13
|
+
*/
|
|
14
|
+
async function getOra() {
|
|
15
|
+
if (_ora) return _ora;
|
|
16
|
+
try {
|
|
17
|
+
const mod = await import('ora');
|
|
18
|
+
_ora = mod.default || mod;
|
|
19
|
+
} catch {
|
|
20
|
+
// Fallback: no-op spinner for environments where ora can't load
|
|
21
|
+
_ora = ({ text }) => ({
|
|
22
|
+
start() { if (text) process.stderr.write(text + '\n'); return this; },
|
|
23
|
+
stop() { return this; },
|
|
24
|
+
succeed() { return this; },
|
|
25
|
+
fail() { return this; },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return _ora;
|
|
29
|
+
}
|
|
6
30
|
|
|
7
31
|
// Semantic color helpers
|
|
8
32
|
const ui = {
|
|
@@ -40,8 +64,33 @@ const ui = {
|
|
|
40
64
|
return s;
|
|
41
65
|
},
|
|
42
66
|
|
|
43
|
-
|
|
44
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Create a spinner. Returns an object with start()/stop().
|
|
69
|
+
* Because ora is loaded async, this returns a proxy that buffers
|
|
70
|
+
* the start call until ora is ready.
|
|
71
|
+
* @param {string} text
|
|
72
|
+
* @returns {{ start: Function, stop: Function }}
|
|
73
|
+
*/
|
|
74
|
+
spinner: (text) => {
|
|
75
|
+
let realSpinner = null;
|
|
76
|
+
let started = false;
|
|
77
|
+
const proxy = {
|
|
78
|
+
start() {
|
|
79
|
+
started = true;
|
|
80
|
+
getOra().then(ora => {
|
|
81
|
+
realSpinner = ora({ text, color: 'cyan' });
|
|
82
|
+
if (started) realSpinner.start();
|
|
83
|
+
});
|
|
84
|
+
return proxy;
|
|
85
|
+
},
|
|
86
|
+
stop() {
|
|
87
|
+
started = false;
|
|
88
|
+
if (realSpinner) realSpinner.stop();
|
|
89
|
+
return proxy;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
return proxy;
|
|
93
|
+
},
|
|
45
94
|
};
|
|
46
95
|
|
|
47
96
|
module.exports = ui;
|