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.
@@ -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 };
@@ -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
- const oraModule = require('ora');
5
- const ora = oraModule.default || oraModule;
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
- // Spinner
44
- spinner: (text) => ora({ text, color: 'cyan' }),
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;