voyageai-cli 1.30.2 → 1.30.5
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 +20 -0
- package/package.json +3 -2
- package/src/commands/embed.js +121 -2
- package/src/commands/playground.js +139 -5
- package/src/commands/workflow.js +30 -0
- package/src/lib/api.js +31 -0
- package/src/lib/config.js +3 -1
- package/src/lib/cost-display.js +1 -1
- package/src/lib/input.js +92 -1
- package/src/lib/integration-test-runner.js +459 -0
- package/src/lib/workflow.js +50 -22
- package/src/mcp/schemas/index.js +12 -0
- package/src/mcp/tools/embedding.js +72 -3
- package/src/mcp/tools/management.js +2 -1
- package/src/playground/index.html +753 -82
package/README.md
CHANGED
|
@@ -512,6 +512,26 @@ echo "your-key" | vai config set api-key --stdin
|
|
|
512
512
|
vai config set mongodb-uri "mongodb+srv://..."
|
|
513
513
|
```
|
|
514
514
|
|
|
515
|
+
#### All Config Keys
|
|
516
|
+
|
|
517
|
+
| CLI Key | Description | Example |
|
|
518
|
+
|---------|-------------|---------|
|
|
519
|
+
| `api-key` | Voyage AI API key | `vai config set api-key pa-...` |
|
|
520
|
+
| `mongodb-uri` | MongoDB Atlas connection string | `vai config set mongodb-uri "mongodb+srv://..."` |
|
|
521
|
+
| `base-url` | Override API endpoint (Atlas AI or Voyage) | `vai config set base-url https://ai.mongodb.com/v1` |
|
|
522
|
+
| `default-model` | Default embedding model | `vai config set default-model voyage-3` |
|
|
523
|
+
| `default-dimensions` | Default output dimensions | `vai config set default-dimensions 512` |
|
|
524
|
+
| `default-db` | Default MongoDB database for workflows/commands | `vai config set default-db my_knowledge_base` |
|
|
525
|
+
| `default-collection` | Default MongoDB collection for workflows/commands | `vai config set default-collection documents` |
|
|
526
|
+
| `llm-provider` | LLM provider for chat/generate (`anthropic`, `openai`, `ollama`) | `vai config set llm-provider anthropic` |
|
|
527
|
+
| `llm-api-key` | LLM provider API key | `vai config set llm-api-key sk-...` |
|
|
528
|
+
| `llm-model` | LLM model override | `vai config set llm-model claude-sonnet-4-5-20250929` |
|
|
529
|
+
| `llm-base-url` | LLM endpoint override (e.g. for Ollama) | `vai config set llm-base-url http://localhost:11434` |
|
|
530
|
+
| `show-cost` | Show cost estimates after operations | `vai config set show-cost true` |
|
|
531
|
+
| `telemetry` | Enable/disable anonymous usage telemetry | `vai config set telemetry false` |
|
|
532
|
+
|
|
533
|
+
Config is stored in `~/.vai/config.json`. Use `vai config get` to see all values (secrets are masked) or `vai config get <key>` for a specific value. The desktop app's Settings → Database page also reads and writes this file.
|
|
534
|
+
|
|
515
535
|
### Shell Completions
|
|
516
536
|
|
|
517
537
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "voyageai-cli",
|
|
3
|
-
"version": "1.30.
|
|
3
|
+
"version": "1.30.5",
|
|
4
4
|
"description": "CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search",
|
|
5
5
|
"bin": {
|
|
6
6
|
"vai": "./src/cli.js"
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
"url": "https://github.com/mrlynn/voyageai-cli/issues"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
|
-
"test": "node --test test/**/*.test.js"
|
|
37
|
+
"test": "node --test test/**/*.test.js",
|
|
38
|
+
"release": "./scripts/release.sh"
|
|
38
39
|
},
|
|
39
40
|
"engines": {
|
|
40
41
|
"node": ">=20.0.0"
|
package/src/commands/embed.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { getDefaultModel } = require('../lib/catalog');
|
|
4
|
-
const { generateEmbeddings } = require('../lib/api');
|
|
5
|
-
const { resolveTextInput } = require('../lib/input');
|
|
4
|
+
const { generateEmbeddings, generateMultimodalEmbeddings } = require('../lib/api');
|
|
5
|
+
const { resolveTextInput, readMediaAsBase64, isImageFile, isVideoFile } = require('../lib/input');
|
|
6
6
|
const ui = require('../lib/ui');
|
|
7
7
|
const { showCostSummary } = require('../lib/cost-display');
|
|
8
8
|
|
|
9
|
+
const MULTIMODAL_MODEL = 'voyage-multimodal-3.5';
|
|
10
|
+
|
|
9
11
|
/**
|
|
10
12
|
* Register the embed command on a Commander program.
|
|
11
13
|
* @param {import('commander').Command} program
|
|
@@ -18,6 +20,8 @@ function registerEmbed(program) {
|
|
|
18
20
|
.option('-t, --input-type <type>', 'Input type: query or document')
|
|
19
21
|
.option('-d, --dimensions <n>', 'Output dimensions', (v) => parseInt(v, 10))
|
|
20
22
|
.option('-f, --file <path>', 'Read text from file')
|
|
23
|
+
.option('--image <path>', 'Embed an image file (uses voyage-multimodal-3.5)')
|
|
24
|
+
.option('--video <path>', 'Embed a video file (uses voyage-multimodal-3.5)')
|
|
21
25
|
.option('--truncation', 'Enable truncation for long inputs')
|
|
22
26
|
.option('--no-truncation', 'Disable truncation')
|
|
23
27
|
.option('--output-dtype <type>', 'Output data type: float, int8, uint8, binary, ubinary', 'float')
|
|
@@ -28,6 +32,121 @@ function registerEmbed(program) {
|
|
|
28
32
|
.action(async (text, opts) => {
|
|
29
33
|
try {
|
|
30
34
|
const telemetry = require('../lib/telemetry');
|
|
35
|
+
const isMultimodal = !!(opts.image || opts.video);
|
|
36
|
+
|
|
37
|
+
// Validate: --image/--video are incompatible with --file
|
|
38
|
+
if (isMultimodal && opts.file) {
|
|
39
|
+
console.error(ui.error('Cannot combine --image or --video with --file. Use --image/--video for multimodal, or --file for text.'));
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Multimodal path: --image and/or --video
|
|
44
|
+
if (isMultimodal) {
|
|
45
|
+
const model = opts.model === getDefaultModel() ? MULTIMODAL_MODEL : opts.model;
|
|
46
|
+
const useColor = !opts.json;
|
|
47
|
+
const useSpinner = useColor && !opts.quiet;
|
|
48
|
+
|
|
49
|
+
// Build content array
|
|
50
|
+
const contentItems = [];
|
|
51
|
+
const mediaMeta = [];
|
|
52
|
+
|
|
53
|
+
// Add text if provided
|
|
54
|
+
if (text) {
|
|
55
|
+
contentItems.push({ type: 'text', text });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Add image
|
|
59
|
+
if (opts.image) {
|
|
60
|
+
if (!isImageFile(opts.image)) {
|
|
61
|
+
console.error(ui.error(`Not a supported image format: ${opts.image}`));
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
const media = readMediaAsBase64(opts.image);
|
|
65
|
+
contentItems.push({ type: 'image_base64', image_base64: media.base64DataUrl });
|
|
66
|
+
mediaMeta.push({ type: 'image', path: opts.image, mime: media.mimeType, size: media.sizeBytes });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Add video
|
|
70
|
+
if (opts.video) {
|
|
71
|
+
if (!isVideoFile(opts.video)) {
|
|
72
|
+
console.error(ui.error(`Not a supported video format: ${opts.video}`));
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
const media = readMediaAsBase64(opts.video);
|
|
76
|
+
contentItems.push({ type: 'video_base64', video_base64: media.base64DataUrl });
|
|
77
|
+
mediaMeta.push({ type: 'video', path: opts.video, mime: media.mimeType, size: media.sizeBytes });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (contentItems.length === 0) {
|
|
81
|
+
console.error(ui.error('No content provided. Pass text, --image, or --video.'));
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const done = telemetry.timer('cli_embed', {
|
|
86
|
+
model,
|
|
87
|
+
multimodal: true,
|
|
88
|
+
hasText: !!text,
|
|
89
|
+
hasImage: !!opts.image,
|
|
90
|
+
hasVideo: !!opts.video,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
let spin;
|
|
94
|
+
if (useSpinner) {
|
|
95
|
+
spin = ui.spinner('Generating multimodal embeddings...');
|
|
96
|
+
spin.start();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const mmOpts = { model };
|
|
100
|
+
if (opts.inputType) mmOpts.inputType = opts.inputType;
|
|
101
|
+
if (opts.dimensions) mmOpts.outputDimension = opts.dimensions;
|
|
102
|
+
|
|
103
|
+
const result = await generateMultimodalEmbeddings([contentItems], mmOpts);
|
|
104
|
+
|
|
105
|
+
if (spin) spin.stop();
|
|
106
|
+
|
|
107
|
+
if (opts.outputFormat === 'array') {
|
|
108
|
+
console.log(JSON.stringify(result.data[0].embedding));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (opts.json) {
|
|
113
|
+
console.log(JSON.stringify(result, null, 2));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Friendly output
|
|
118
|
+
if (!opts.quiet) {
|
|
119
|
+
console.log(ui.label('Model', ui.cyan(model)));
|
|
120
|
+
console.log(ui.label('Mode', ui.cyan('multimodal')));
|
|
121
|
+
for (const m of mediaMeta) {
|
|
122
|
+
const sizeStr = m.size < 1024 * 1024
|
|
123
|
+
? `${(m.size / 1024).toFixed(1)} KB`
|
|
124
|
+
: `${(m.size / (1024 * 1024)).toFixed(1)} MB`;
|
|
125
|
+
console.log(ui.label(m.type === 'image' ? 'Image' : 'Video', `${m.path} ${ui.dim(`(${m.mime}, ${sizeStr})`)}`));
|
|
126
|
+
}
|
|
127
|
+
if (text) {
|
|
128
|
+
console.log(ui.label('Text', ui.dim(text.slice(0, 80) + (text.length > 80 ? '...' : ''))));
|
|
129
|
+
}
|
|
130
|
+
if (result.usage) {
|
|
131
|
+
console.log(ui.label('Tokens', ui.dim(String(result.usage.total_tokens))));
|
|
132
|
+
}
|
|
133
|
+
const dims = result.data[0]?.embedding?.length || 'N/A';
|
|
134
|
+
console.log(ui.label('Dimensions', ui.bold(String(dims))));
|
|
135
|
+
console.log('');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const vector = result.data[0].embedding;
|
|
139
|
+
const preview = vector.slice(0, 5).map(v => v.toFixed(6)).join(', ');
|
|
140
|
+
console.log(`[${preview}, ...] (${vector.length} dims)`);
|
|
141
|
+
|
|
142
|
+
console.log('');
|
|
143
|
+
console.log(ui.success('Multimodal embedding generated'));
|
|
144
|
+
|
|
145
|
+
done({ dimensions: result.data[0]?.embedding?.length });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Standard text embedding path
|
|
31
150
|
const texts = await resolveTextInput(text, opts.file);
|
|
32
151
|
|
|
33
152
|
// --estimate: show cost comparison, optionally switch model
|
|
@@ -541,6 +541,87 @@ function createPlaygroundServer() {
|
|
|
541
541
|
return;
|
|
542
542
|
}
|
|
543
543
|
|
|
544
|
+
// API: Settings — read/write ~/.vai/config.json
|
|
545
|
+
if (req.method === 'GET' && req.url === '/api/settings') {
|
|
546
|
+
const { loadConfig, KEY_MAP, SECRET_KEYS, maskSecret } = require('../lib/config');
|
|
547
|
+
const config = loadConfig();
|
|
548
|
+
|
|
549
|
+
// Build response: CLI key name → masked/raw value, for every known key
|
|
550
|
+
const reverseMap = {};
|
|
551
|
+
for (const [cliKey, internalKey] of Object.entries(KEY_MAP)) {
|
|
552
|
+
reverseMap[internalKey] = cliKey;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const settings = {};
|
|
556
|
+
for (const [internalKey, cliKey] of Object.entries(reverseMap)) {
|
|
557
|
+
const value = config[internalKey];
|
|
558
|
+
settings[cliKey] = {
|
|
559
|
+
value: value != null ? (SECRET_KEYS.has(internalKey) ? maskSecret(value) : value) : null,
|
|
560
|
+
isSet: value != null,
|
|
561
|
+
isSecret: SECRET_KEYS.has(internalKey),
|
|
562
|
+
internalKey,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
567
|
+
res.end(JSON.stringify(settings));
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (req.method === 'PUT' && req.url === '/api/settings') {
|
|
572
|
+
const { loadConfig, saveConfig, KEY_MAP, SECRET_KEYS } = require('../lib/config');
|
|
573
|
+
const body = await readBody(req);
|
|
574
|
+
let updates;
|
|
575
|
+
try {
|
|
576
|
+
updates = JSON.parse(body);
|
|
577
|
+
} catch {
|
|
578
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
579
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const config = loadConfig();
|
|
584
|
+
const applied = [];
|
|
585
|
+
|
|
586
|
+
for (const [cliKey, value] of Object.entries(updates)) {
|
|
587
|
+
const internalKey = KEY_MAP[cliKey];
|
|
588
|
+
if (!internalKey) {
|
|
589
|
+
continue; // Skip unknown keys
|
|
590
|
+
}
|
|
591
|
+
// Don't overwrite secrets with masked values
|
|
592
|
+
if (SECRET_KEYS.has(internalKey) && typeof value === 'string' && value.includes('...')) {
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (value === null || value === '') {
|
|
596
|
+
delete config[internalKey];
|
|
597
|
+
} else {
|
|
598
|
+
config[internalKey] = value;
|
|
599
|
+
}
|
|
600
|
+
applied.push(cliKey);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
saveConfig(config);
|
|
604
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
605
|
+
res.end(JSON.stringify({ applied, message: `Updated ${applied.length} setting(s)` }));
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// API: Settings reveal — return unmasked value for a specific secret key
|
|
610
|
+
if (req.method === 'GET' && req.url.startsWith('/api/settings/reveal/')) {
|
|
611
|
+
const { loadConfig, KEY_MAP, SECRET_KEYS } = require('../lib/config');
|
|
612
|
+
const cliKey = req.url.replace('/api/settings/reveal/', '');
|
|
613
|
+
const internalKey = KEY_MAP[cliKey];
|
|
614
|
+
if (!internalKey || !SECRET_KEYS.has(internalKey)) {
|
|
615
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
616
|
+
res.end(JSON.stringify({ error: 'Not found or not a secret key' }));
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const config = loadConfig();
|
|
620
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
621
|
+
res.end(JSON.stringify({ value: config[internalKey] || null }));
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
544
625
|
// API: Settings origins — where each config value comes from
|
|
545
626
|
if (req.method === 'GET' && req.url === '/api/settings/origins') {
|
|
546
627
|
const { resolveLLMConfig } = require('../lib/llm');
|
|
@@ -561,8 +642,8 @@ function createPlaygroundServer() {
|
|
|
561
642
|
provider: resolveOrigin('VAI_LLM_PROVIDER', 'llmProvider', chatConf.provider),
|
|
562
643
|
model: resolveOrigin('VAI_LLM_MODEL', 'llmModel', chatConf.model),
|
|
563
644
|
llmApiKey: resolveOrigin('VAI_LLM_API_KEY', 'llmApiKey'),
|
|
564
|
-
db:
|
|
565
|
-
collection:
|
|
645
|
+
db: resolveOrigin(null, 'defaultDb', proj.db),
|
|
646
|
+
collection: resolveOrigin(null, 'defaultCollection', proj.collection),
|
|
566
647
|
};
|
|
567
648
|
|
|
568
649
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -1406,9 +1487,58 @@ function createPlaygroundServer() {
|
|
|
1406
1487
|
res.end(JSON.stringify({ error: 'inputs must be a non-empty array' }));
|
|
1407
1488
|
return;
|
|
1408
1489
|
}
|
|
1490
|
+
|
|
1491
|
+
// Optimize video inputs: downsample to 1fps to fit within 32k token context
|
|
1492
|
+
const os = require('os');
|
|
1493
|
+
const path = require('path');
|
|
1494
|
+
const fs = require('fs');
|
|
1495
|
+
const { execFileSync } = require('child_process');
|
|
1496
|
+
const optimizedInputs = [];
|
|
1497
|
+
for (const input of inputs) {
|
|
1498
|
+
const content = input.content;
|
|
1499
|
+
if (content && Array.isArray(content)) {
|
|
1500
|
+
const optimizedContent = [];
|
|
1501
|
+
for (const item of content) {
|
|
1502
|
+
if (item.type === 'video_base64' && item.video_base64) {
|
|
1503
|
+
// Downsample video to 1fps using ffmpeg to reduce token count
|
|
1504
|
+
try {
|
|
1505
|
+
const b64 = item.video_base64.replace(/^data:[^;]+;base64,/, '');
|
|
1506
|
+
const tmpIn = path.join(os.tmpdir(), `vai_vid_in_${Date.now()}.mp4`);
|
|
1507
|
+
const tmpOut = path.join(os.tmpdir(), `vai_vid_out_${Date.now()}.mp4`);
|
|
1508
|
+
fs.writeFileSync(tmpIn, Buffer.from(b64, 'base64'));
|
|
1509
|
+
try {
|
|
1510
|
+
execFileSync('ffmpeg', [
|
|
1511
|
+
'-y', '-i', tmpIn,
|
|
1512
|
+
'-vf', 'fps=1',
|
|
1513
|
+
'-c:v', 'libx264', '-preset', 'fast', '-crf', '23',
|
|
1514
|
+
'-an', // strip audio
|
|
1515
|
+
tmpOut
|
|
1516
|
+
], { timeout: 30000, stdio: 'pipe' });
|
|
1517
|
+
const optimizedBuf = fs.readFileSync(tmpOut);
|
|
1518
|
+
const optimizedB64 = `data:video/mp4;base64,${optimizedBuf.toString('base64')}`;
|
|
1519
|
+
optimizedContent.push({ type: 'video_base64', video_base64: optimizedB64 });
|
|
1520
|
+
} finally {
|
|
1521
|
+
try { fs.unlinkSync(tmpIn); } catch (_) {}
|
|
1522
|
+
try { fs.unlinkSync(tmpOut); } catch (_) {}
|
|
1523
|
+
}
|
|
1524
|
+
} catch (err) {
|
|
1525
|
+
// If optimization fails, send original and let API error naturally
|
|
1526
|
+
console.warn('[Playground] Video optimization failed:', err.message);
|
|
1527
|
+
optimizedContent.push(item);
|
|
1528
|
+
}
|
|
1529
|
+
} else {
|
|
1530
|
+
optimizedContent.push(item);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
optimizedInputs.push({ ...input, content: optimizedContent });
|
|
1534
|
+
} else {
|
|
1535
|
+
optimizedInputs.push(input);
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1409
1539
|
const { apiRequest } = require('../lib/api');
|
|
1410
1540
|
const mmBody = {
|
|
1411
|
-
inputs,
|
|
1541
|
+
inputs: optimizedInputs,
|
|
1412
1542
|
model: model || 'voyage-multimodal-3.5',
|
|
1413
1543
|
};
|
|
1414
1544
|
if (input_type) mmBody.input_type = input_type;
|
|
@@ -1590,9 +1720,13 @@ function createPlaygroundServer() {
|
|
|
1590
1720
|
else if (output.text) summary = output.text.slice(0, 100) + (output.text.length > 100 ? '...' : '');
|
|
1591
1721
|
else summary = JSON.stringify(output).slice(0, 200);
|
|
1592
1722
|
}
|
|
1723
|
+
// Extract usage data for cost tracking (then strip from output payload)
|
|
1724
|
+
const _usage = (output && output._usage) ? output._usage : undefined;
|
|
1725
|
+
const cleanOutput = _usage ? { ...output } : output;
|
|
1726
|
+
if (cleanOutput && cleanOutput._usage) delete cleanOutput._usage;
|
|
1593
1727
|
res.write(`event: step_complete\ndata: ${JSON.stringify({
|
|
1594
|
-
stepId, timeMs, summary,
|
|
1595
|
-
output: JSON.stringify(
|
|
1728
|
+
stepId, timeMs, summary, _usage,
|
|
1729
|
+
output: JSON.stringify(cleanOutput).length < 5000 ? cleanOutput : { _truncated: true, summary },
|
|
1596
1730
|
})}\n\n`);
|
|
1597
1731
|
},
|
|
1598
1732
|
onStepSkip: (stepId, reason) => {
|
package/src/commands/workflow.js
CHANGED
|
@@ -529,6 +529,36 @@ function registerWorkflow(program) {
|
|
|
529
529
|
}
|
|
530
530
|
});
|
|
531
531
|
|
|
532
|
+
// ── workflow integration-test ──
|
|
533
|
+
wfCmd
|
|
534
|
+
.command('integration-test')
|
|
535
|
+
.description('Run live integration tests against use-case domain datasets')
|
|
536
|
+
.option('--domain <slug>', 'Test only a specific domain (devdocs, healthcare, finance, legal)')
|
|
537
|
+
.option('--workflow <name>', 'Test only a specific workflow')
|
|
538
|
+
.option('--no-seed', 'Skip seeding (assumes data already exists)')
|
|
539
|
+
.option('--teardown', 'Drop test collections after running')
|
|
540
|
+
.option('--sample-docs <path>', 'Override base path for sample documents')
|
|
541
|
+
.option('--json', 'Output machine-readable JSON', false)
|
|
542
|
+
.action(async (opts) => {
|
|
543
|
+
// Delegate to the integration test runner
|
|
544
|
+
const { execFileSync } = require('child_process');
|
|
545
|
+
const runnerPath = path.join(__dirname, '../../test/integration/run.js');
|
|
546
|
+
const args = [];
|
|
547
|
+
if (opts.domain) args.push('--domain', opts.domain);
|
|
548
|
+
if (opts.workflow) args.push('--workflow', opts.workflow);
|
|
549
|
+
if (opts.seed === false) args.push('--no-seed');
|
|
550
|
+
if (opts.teardown) args.push('--teardown');
|
|
551
|
+
if (opts.sampleDocs) args.push('--sample-docs', opts.sampleDocs);
|
|
552
|
+
if (opts.json) args.push('--json');
|
|
553
|
+
|
|
554
|
+
try {
|
|
555
|
+
execFileSync(process.execPath, [runnerPath, ...args], { stdio: 'inherit' });
|
|
556
|
+
} catch (err) {
|
|
557
|
+
if (err.status) process.exit(err.status);
|
|
558
|
+
throw err;
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
|
|
532
562
|
// ── workflow validate <file> ──
|
|
533
563
|
wfCmd
|
|
534
564
|
.command('validate <file>')
|
package/src/lib/api.js
CHANGED
|
@@ -195,6 +195,36 @@ async function generateEmbeddings(texts, options = {}) {
|
|
|
195
195
|
return apiRequest('/embeddings', body);
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Generate multimodal embeddings for inputs containing text, images, and/or video.
|
|
200
|
+
* Uses the /multimodalembeddings endpoint with a different input format.
|
|
201
|
+
* @param {Array<Array<{type: string, text?: string, image_base64?: string, video_base64?: string}>>} inputs
|
|
202
|
+
* Array of content arrays. Each content array is a list of content items for one input.
|
|
203
|
+
* Example: [[{type: 'text', text: 'hello'}, {type: 'image_base64', image_base64: 'data:image/png;base64,...'}]]
|
|
204
|
+
* @param {object} options
|
|
205
|
+
* @param {string} [options.model] - Model name (default: voyage-multimodal-3.5)
|
|
206
|
+
* @param {string} [options.inputType] - Input type (query|document)
|
|
207
|
+
* @param {number} [options.outputDimension] - Output dimensions
|
|
208
|
+
* @returns {Promise<object>} API response with embeddings
|
|
209
|
+
*/
|
|
210
|
+
async function generateMultimodalEmbeddings(inputs, options = {}) {
|
|
211
|
+
const model = options.model || 'voyage-multimodal-3.5';
|
|
212
|
+
|
|
213
|
+
const body = {
|
|
214
|
+
inputs: inputs.map(contentArray => ({ content: contentArray })),
|
|
215
|
+
model,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
if (options.inputType) {
|
|
219
|
+
body.input_type = options.inputType;
|
|
220
|
+
}
|
|
221
|
+
if (options.outputDimension) {
|
|
222
|
+
body.output_dimension = options.outputDimension;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return apiRequest('/multimodalembeddings', body);
|
|
226
|
+
}
|
|
227
|
+
|
|
198
228
|
module.exports = {
|
|
199
229
|
API_BASE,
|
|
200
230
|
ATLAS_API_BASE,
|
|
@@ -204,4 +234,5 @@ module.exports = {
|
|
|
204
234
|
requireApiKey,
|
|
205
235
|
apiRequest,
|
|
206
236
|
generateEmbeddings,
|
|
237
|
+
generateMultimodalEmbeddings,
|
|
207
238
|
};
|
package/src/lib/config.js
CHANGED
|
@@ -18,7 +18,9 @@ const KEY_MAP = {
|
|
|
18
18
|
'llm-api-key': 'llmApiKey',
|
|
19
19
|
'llm-model': 'llmModel',
|
|
20
20
|
'llm-base-url': 'llmBaseUrl',
|
|
21
|
-
'
|
|
21
|
+
'default-db': 'defaultDb',
|
|
22
|
+
'default-collection': 'defaultCollection',
|
|
23
|
+
'show-cost': 'showCost',
|
|
22
24
|
'telemetry': 'telemetry',
|
|
23
25
|
};
|
|
24
26
|
|
package/src/lib/cost-display.js
CHANGED
|
@@ -89,7 +89,7 @@ function showCombinedCostSummary(operations, opts = {}) {
|
|
|
89
89
|
* @returns {boolean}
|
|
90
90
|
*/
|
|
91
91
|
function isEnabled() {
|
|
92
|
-
const val = getConfigValue('show-cost');
|
|
92
|
+
const val = getConfigValue('showCost') || getConfigValue('show-cost');
|
|
93
93
|
return val === true || val === 'true';
|
|
94
94
|
}
|
|
95
95
|
|
package/src/lib/input.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Read text input from argument, --file flag, or stdin.
|
|
@@ -37,4 +38,94 @@ async function resolveTextInput(textArg, filePath) {
|
|
|
37
38
|
process.exit(1);
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
/**
|
|
42
|
+
* MIME type mappings for supported image formats.
|
|
43
|
+
*/
|
|
44
|
+
const IMAGE_MIME_TYPES = {
|
|
45
|
+
'.jpg': 'image/jpeg',
|
|
46
|
+
'.jpeg': 'image/jpeg',
|
|
47
|
+
'.png': 'image/png',
|
|
48
|
+
'.gif': 'image/gif',
|
|
49
|
+
'.webp': 'image/webp',
|
|
50
|
+
'.bmp': 'image/bmp',
|
|
51
|
+
'.tiff': 'image/tiff',
|
|
52
|
+
'.tif': 'image/tiff',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* MIME type mappings for supported video formats.
|
|
57
|
+
*/
|
|
58
|
+
const VIDEO_MIME_TYPES = {
|
|
59
|
+
'.mp4': 'video/mp4',
|
|
60
|
+
'.mpeg': 'video/mpeg',
|
|
61
|
+
'.mpg': 'video/mpeg',
|
|
62
|
+
'.mov': 'video/quicktime',
|
|
63
|
+
'.avi': 'video/x-msvideo',
|
|
64
|
+
'.mkv': 'video/x-matroska',
|
|
65
|
+
'.webm': 'video/webm',
|
|
66
|
+
'.flv': 'video/x-flv',
|
|
67
|
+
'.wmv': 'video/x-ms-wmv',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check if a file path is a supported image format.
|
|
72
|
+
* @param {string} filePath
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
function isImageFile(filePath) {
|
|
76
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
77
|
+
return ext in IMAGE_MIME_TYPES;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if a file path is a supported video format.
|
|
82
|
+
* @param {string} filePath
|
|
83
|
+
* @returns {boolean}
|
|
84
|
+
*/
|
|
85
|
+
function isVideoFile(filePath) {
|
|
86
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
87
|
+
return ext in VIDEO_MIME_TYPES;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read a media file (image or video) and return it as a base64 data URL.
|
|
92
|
+
* @param {string} filePath - Path to the media file
|
|
93
|
+
* @returns {{ base64DataUrl: string, mimeType: string, sizeBytes: number }}
|
|
94
|
+
*/
|
|
95
|
+
function readMediaAsBase64(filePath) {
|
|
96
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
97
|
+
const mimeType = IMAGE_MIME_TYPES[ext] || VIDEO_MIME_TYPES[ext];
|
|
98
|
+
|
|
99
|
+
if (!mimeType) {
|
|
100
|
+
const supported = [
|
|
101
|
+
...Object.keys(IMAGE_MIME_TYPES),
|
|
102
|
+
...Object.keys(VIDEO_MIME_TYPES),
|
|
103
|
+
].join(', ');
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Unsupported media format "${ext}". Supported: ${supported}`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!fs.existsSync(filePath)) {
|
|
110
|
+
throw new Error(`File not found: ${filePath}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const buffer = fs.readFileSync(filePath);
|
|
114
|
+
const base64 = buffer.toString('base64');
|
|
115
|
+
const base64DataUrl = `data:${mimeType};base64,${base64}`;
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
base64DataUrl,
|
|
119
|
+
mimeType,
|
|
120
|
+
sizeBytes: buffer.length,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
resolveTextInput,
|
|
126
|
+
readMediaAsBase64,
|
|
127
|
+
isImageFile,
|
|
128
|
+
isVideoFile,
|
|
129
|
+
IMAGE_MIME_TYPES,
|
|
130
|
+
VIDEO_MIME_TYPES,
|
|
131
|
+
};
|