badgr-cli 1.1.0 → 1.1.2

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.
Files changed (66) hide show
  1. package/LICENSE +207 -0
  2. package/README.md +135 -3
  3. package/package.json +44 -2
  4. package/src/api.js +16 -0
  5. package/src/badgr.js +2 -2
  6. package/src/commands/batch.js +11 -0
  7. package/src/commands/comfyui.js +31 -15
  8. package/src/commands/embed.js +13 -10
  9. package/src/commands/launch.js +8 -1
  10. package/src/commands/login.js +75 -20
  11. package/src/commands/run.js +45 -13
  12. package/src/commands/sbatch.js +6 -1
  13. package/src/commands/serve.js +44 -30
  14. package/src/commands/train.js +8 -12
  15. package/src/commands/transcribe.js +13 -10
  16. package/src/envFlag.js +10 -0
  17. package/src/onboarding.js +8 -1
  18. package/src/progress.js +48 -0
  19. package/tests/agent-images.test.js +0 -17
  20. package/tests/api.test.js +0 -168
  21. package/tests/artifactDownload.test.js +0 -113
  22. package/tests/artifacts.test.js +0 -168
  23. package/tests/batch.test.js +0 -641
  24. package/tests/browser.test.js +0 -51
  25. package/tests/capacity.test.js +0 -68
  26. package/tests/commands.test.js +0 -417
  27. package/tests/config.test.js +0 -96
  28. package/tests/connect.test.js +0 -83
  29. package/tests/detect.test.js +0 -191
  30. package/tests/down.test.js +0 -150
  31. package/tests/errors.test.js +0 -130
  32. package/tests/fallback-timeout.test.js +0 -41
  33. package/tests/fanout.test.js +0 -124
  34. package/tests/gpu-doctor-classifiers.test.js +0 -402
  35. package/tests/gpu-doctor-doctor.test.js +0 -304
  36. package/tests/gpu-doctor-probe-cache.test.js +0 -110
  37. package/tests/gpu-doctor-probes.test.js +0 -257
  38. package/tests/heartbeat.test.js +0 -70
  39. package/tests/job-progress-poll.test.js +0 -136
  40. package/tests/launch-command-argv.test.js +0 -93
  41. package/tests/launch-readiness.test.js +0 -403
  42. package/tests/launch.test.js +0 -440
  43. package/tests/onboarding.test.js +0 -134
  44. package/tests/productized-dry-run.test.js +0 -141
  45. package/tests/productized-runners.test.js +0 -237
  46. package/tests/pull.test.js +0 -266
  47. package/tests/rerun.test.js +0 -94
  48. package/tests/restart.test.js +0 -88
  49. package/tests/router.test.js +0 -98
  50. package/tests/run-lifecycle.test.js +0 -1054
  51. package/tests/sbatch.test.js +0 -190
  52. package/tests/secrets.test.js +0 -16
  53. package/tests/serve-apps.test.js +0 -189
  54. package/tests/serve-lifecycle.test.js +0 -931
  55. package/tests/slurm.test.js +0 -77
  56. package/tests/spec.test.js +0 -201
  57. package/tests/status.test.js +0 -73
  58. package/tests/store.test.js +0 -187
  59. package/tests/task.test.js +0 -109
  60. package/tests/template.test.js +0 -556
  61. package/tests/train-lora-dataset.test.js +0 -176
  62. package/tests/upload.test.js +0 -79
  63. package/tests/workload-rerun.test.js +0 -56
  64. package/tests/workload-spec.test.js +0 -180
  65. package/tests/workload-templates.test.js +0 -865
  66. package/tests/workload-workspace-paths.test.js +0 -46
@@ -1,865 +0,0 @@
1
- /**
2
- * Workload template tests: comfyui, train, transcribe, embed
3
- *
4
- * Covers arg parsing, input validation, API dispatch, and receipt recording.
5
- */
6
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
7
- import { parseComfyuiArgs, comfyuiCommand } from '../src/commands/comfyui.js';
8
- import { parseTrainArgs, detectFramework, findLocalDatasetPaths, trainCommand } from '../src/commands/train.js';
9
- import { parseTranscribeArgs, resolveAudioInput, transcribeCommand } from '../src/commands/transcribe.js';
10
- import { parseEmbedArgs, resolveEmbedInput, embedCommand } from '../src/commands/embed.js';
11
-
12
- // ── Module mocks ──────────────────────────────────────────────────────────────
13
-
14
- vi.mock('../src/api.js', () => ({
15
- callApi: vi.fn(),
16
- terminateDeployment: vi.fn().mockResolvedValue({}),
17
- listDeployments: vi.fn().mockResolvedValue({ deployments: [], count: 0 }),
18
- }));
19
-
20
- vi.mock('../src/store.js', () => ({
21
- addDeployment: vi.fn(),
22
- addReceipt: vi.fn(),
23
- updateReceipt: vi.fn(),
24
- generateReceiptId: vi.fn(() => 'rcpt-test-001'),
25
- generateDeploymentId: vi.fn(() => 'dep-test-001'),
26
- listDeployments: vi.fn(() => []),
27
- listReceipts: vi.fn(() => []),
28
- findDeployment: vi.fn(() => null),
29
- updateDeployment: vi.fn(),
30
- removeDeployment: vi.fn(),
31
- }));
32
-
33
- // Mock callWithFallback directly so tests don't depend on API call ordering.
34
- vi.mock('../src/fallback.js', async (importOriginal) => {
35
- const actual = await importOriginal();
36
- return {
37
- ...actual,
38
- callWithFallback: vi.fn(),
39
- };
40
- });
41
-
42
- vi.mock('fs', async (importOriginal) => {
43
- const actual = await importOriginal();
44
- return {
45
- ...actual,
46
- readFileSync: vi.fn(),
47
- existsSync: vi.fn(),
48
- statSync: vi.fn(),
49
- };
50
- });
51
-
52
- import * as api from '../src/api.js';
53
- import * as store from '../src/store.js';
54
- import * as fallback from '../src/fallback.js';
55
- import * as fs from 'fs';
56
-
57
- // ── Helpers ───────────────────────────────────────────────────────────────────
58
-
59
- const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
60
-
61
- const chalk = {
62
- bold: s => s, dim: s => s, red: s => s,
63
- yellow: s => s, green: s => s, cyan: s => s,
64
- };
65
-
66
- const ENDPOINT_URL = 'https://dep-test-001.aibadgr.com/v1';
67
-
68
- function makeServeDep(overrides = {}) {
69
- return {
70
- deployment_id: 'dep-test-001',
71
- status: 'running',
72
- gpu_type: 'RTX_4090',
73
- gpu_count: 1,
74
- cost_per_hour: 0.72,
75
- provider: 'test-provider',
76
- receipt_id: 'rcpt-test-001',
77
- tier: '1',
78
- endpoint_url: ENDPOINT_URL,
79
- ...overrides,
80
- };
81
- }
82
-
83
- function makeRunDep(overrides = {}) {
84
- return {
85
- deployment_id: 'dep-test-001',
86
- status: 'queued',
87
- gpu_type: 'RTX_4090',
88
- gpu_count: 1,
89
- cost_per_hour: 0.72,
90
- provider: 'test-provider',
91
- receipt_id: 'rcpt-test-001',
92
- tier: '1',
93
- ...overrides,
94
- };
95
- }
96
-
97
- // ── Setup / teardown ──────────────────────────────────────────────────────────
98
-
99
- beforeEach(() => {
100
- vi.useFakeTimers();
101
- process.exitCode = undefined;
102
- vi.spyOn(console, 'log').mockImplementation(() => {});
103
- vi.spyOn(console, 'error').mockImplementation(() => {});
104
- vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
105
- vi.clearAllMocks();
106
- store.generateReceiptId.mockReturnValue('rcpt-test-001');
107
- api.terminateDeployment.mockResolvedValue({});
108
- api.listDeployments.mockResolvedValue({ deployments: [], count: 0 });
109
- });
110
-
111
- afterEach(() => {
112
- vi.useRealTimers();
113
- vi.restoreAllMocks();
114
- process.exitCode = undefined;
115
- });
116
-
117
- // ─────────────────────────────────────────────────────────────────────────────
118
- // parseComfyuiArgs
119
- // ─────────────────────────────────────────────────────────────────────────────
120
-
121
- describe('parseComfyuiArgs', () => {
122
- it('parses workflow positional after "run" subcommand', () => {
123
- const { workflow, flags } = parseComfyuiArgs(['run', 'workflow.json']);
124
- expect(workflow).toBe('workflow.json');
125
- expect(flags.gpu).toBeUndefined();
126
- });
127
-
128
- it('parses workflow without "run" subcommand', () => {
129
- const { workflow } = parseComfyuiArgs(['workflow.json']);
130
- expect(workflow).toBe('workflow.json');
131
- });
132
-
133
- it('parses --gpu flag', () => {
134
- const { flags } = parseComfyuiArgs(['run', 'wf.json', '--gpu', 'L40S']);
135
- expect(flags.gpu).toBe('L40S');
136
- });
137
-
138
- it('parses --max-cost flag', () => {
139
- const { flags } = parseComfyuiArgs(['wf.json', '--max-cost', '5']);
140
- expect(flags.maxCost).toBe(5);
141
- });
142
-
143
- it('parses --no-wait flag', () => {
144
- const { flags } = parseComfyuiArgs(['wf.json', '--no-wait']);
145
- expect(flags.noWait).toBe(true);
146
- });
147
-
148
- it('parses --check-nodes flag', () => {
149
- const { flags } = parseComfyuiArgs(['wf.json', '--check-nodes', 'CLIPTextEncode,KSampler']);
150
- expect(flags.checkNodes).toBe('CLIPTextEncode,KSampler');
151
- });
152
-
153
- it('parses --persistent flag', () => {
154
- const { flags } = parseComfyuiArgs(['wf.json', '--persistent']);
155
- expect(flags.persistent).toBe(true);
156
- });
157
-
158
- it('returns null workflow when no positional arg', () => {
159
- const { workflow } = parseComfyuiArgs(['--gpu', 'RTX_4090']);
160
- expect(workflow).toBeNull();
161
- });
162
-
163
- it('parses --env flag', () => {
164
- const { flags } = parseComfyuiArgs(['wf.json', '--env', 'FOO=bar', '--max-cost', '5']);
165
- expect(flags.env).toEqual(['FOO=bar']);
166
- });
167
- });
168
-
169
- // ─────────────────────────────────────────────────────────────────────────────
170
- // comfyuiCommand
171
- // ─────────────────────────────────────────────────────────────────────────────
172
-
173
- describe('comfyuiCommand', () => {
174
- it('errors when no workflow provided', async () => {
175
- await comfyuiCommand(config, [], chalk);
176
- expect(process.exitCode).toBe(1);
177
- expect(fallback.callWithFallback).not.toHaveBeenCalled();
178
- });
179
-
180
- it('errors when --max-cost and --persistent are both absent', async () => {
181
- await comfyuiCommand(config, ['run', 'wf.json'], chalk);
182
- expect(process.exitCode).toBe(1);
183
- expect(fallback.callWithFallback).not.toHaveBeenCalled();
184
- });
185
-
186
- it('errors when workflow file does not exist', async () => {
187
- fs.existsSync.mockReturnValue(false);
188
- await comfyuiCommand(config, ['run', 'wf.json', '--max-cost', '5'], chalk);
189
- expect(process.exitCode).toBe(1);
190
- expect(fallback.callWithFallback).not.toHaveBeenCalled();
191
- });
192
-
193
- it('errors when workflow JSON is invalid', async () => {
194
- fs.existsSync.mockReturnValue(true);
195
- fs.readFileSync.mockReturnValue('not valid json {{{');
196
- await comfyuiCommand(config, ['wf.json', '--max-cost', '5'], chalk);
197
- expect(process.exitCode).toBe(1);
198
- });
199
-
200
- it('launches ComfyUI and records deployment', async () => {
201
- const wfContent = JSON.stringify({ '1': { class_type: 'KSampler' } });
202
- fs.existsSync.mockReturnValue(true);
203
- fs.readFileSync.mockReturnValue(wfContent);
204
- fallback.callWithFallback.mockResolvedValue(makeServeDep());
205
-
206
- global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
207
-
208
- const p = comfyuiCommand(config, ['wf.json', '--no-wait', '--max-cost', '5'], chalk);
209
- await vi.advanceTimersByTimeAsync(100);
210
- await p;
211
-
212
- expect(store.addDeployment).toHaveBeenCalledWith(expect.objectContaining({
213
- id: 'dep-test-001',
214
- }));
215
- expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-test-001', expect.objectContaining({
216
- status: 'starting',
217
- }));
218
- expect(process.exitCode).toBeFalsy();
219
- });
220
-
221
- it('passes COMFYUI_WORKFLOW_B64 env var to callWithFallback', async () => {
222
- const wfContent = JSON.stringify({ '1': { class_type: 'CLIPTextEncode' } });
223
- fs.existsSync.mockReturnValue(true);
224
- fs.readFileSync.mockReturnValue(wfContent);
225
- fallback.callWithFallback.mockResolvedValue(makeServeDep());
226
-
227
- const p = comfyuiCommand(config, ['wf.json', '--no-wait', '--max-cost', '5'], chalk);
228
- await vi.advanceTimersByTimeAsync(100);
229
- await p;
230
-
231
- expect(fallback.callWithFallback).toHaveBeenCalledWith(
232
- '/serve',
233
- expect.anything(),
234
- expect.any(Function),
235
- expect.anything(),
236
- expect.anything(),
237
- expect.anything(),
238
- );
239
- const bodyBuilder = fallback.callWithFallback.mock.calls[0][2];
240
- const body = bodyBuilder();
241
- expect(body.env.COMFYUI_WORKFLOW_B64).toBe(Buffer.from(wfContent).toString('base64'));
242
- expect(body.image).toBe('yanwk/comfyui-boot:cu126-megapak');
243
- });
244
-
245
- it('skips health check when --no-wait', async () => {
246
- fs.existsSync.mockReturnValue(true);
247
- fs.readFileSync.mockReturnValue(JSON.stringify({ '1': {} }));
248
- fallback.callWithFallback.mockResolvedValue(makeServeDep());
249
- global.fetch = vi.fn();
250
-
251
- const p = comfyuiCommand(config, ['wf.json', '--no-wait', '--max-cost', '5'], chalk);
252
- await vi.advanceTimersByTimeAsync(100);
253
- await p;
254
-
255
- expect(global.fetch).not.toHaveBeenCalled();
256
- expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-test-001', expect.objectContaining({
257
- status: 'starting',
258
- }));
259
- expect(process.exitCode).toBeFalsy();
260
- });
261
-
262
- it('health-checks /system_stats and records ready state', async () => {
263
- fs.existsSync.mockReturnValue(true);
264
- fs.readFileSync.mockReturnValue(JSON.stringify({ '1': {} }));
265
- fallback.callWithFallback.mockResolvedValue(makeServeDep());
266
-
267
- api.callApi.mockResolvedValue({ status: 'running' }); // dep status polls
268
-
269
- const fetchedUrls = [];
270
- global.fetch = vi.fn().mockImplementation((url) => {
271
- fetchedUrls.push(url);
272
- return Promise.resolve({ ok: true, status: 200 });
273
- });
274
-
275
- const p = comfyuiCommand(config, ['wf.json', '--max-cost', '5'], chalk);
276
- await vi.advanceTimersByTimeAsync(5000);
277
- await p;
278
-
279
- expect(fetchedUrls.some(u => u.includes('/system_stats'))).toBe(true);
280
- expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-test-001', expect.objectContaining({
281
- status: 'ready',
282
- }));
283
- expect(process.exitCode).toBeFalsy();
284
- });
285
-
286
- it('records failed receipt when deployment crashes mid-health-check', async () => {
287
- fs.existsSync.mockReturnValue(true);
288
- fs.readFileSync.mockReturnValue(JSON.stringify({ '1': {} }));
289
- fallback.callWithFallback.mockResolvedValue(makeServeDep());
290
-
291
- api.callApi.mockResolvedValue({ status: 'failed', error: 'OOM' });
292
- global.fetch = vi.fn().mockRejectedValue(new Error('Connection refused'));
293
-
294
- const p = comfyuiCommand(config, ['wf.json', '--max-cost', '5'], chalk);
295
- await vi.advanceTimersByTimeAsync(10000);
296
- await p;
297
-
298
- expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-test-001', expect.objectContaining({
299
- status: 'failed',
300
- }));
301
- expect(process.exitCode).toBe(1);
302
- });
303
-
304
- it('prints failure_class/next_action from the backend using the shared vocabulary', async () => {
305
- fs.existsSync.mockReturnValue(true);
306
- fs.readFileSync.mockReturnValue(JSON.stringify({ '1': {} }));
307
- fallback.callWithFallback.mockResolvedValue(makeServeDep());
308
-
309
- api.callApi.mockResolvedValue({
310
- status: 'failed',
311
- error: 'OOM',
312
- failure_class: 'container_start_failed',
313
- next_action: 'Check the image and command, then retry.',
314
- });
315
- global.fetch = vi.fn().mockRejectedValue(new Error('Connection refused'));
316
-
317
- const p = comfyuiCommand(config, ['wf.json', '--max-cost', '5'], chalk);
318
- await vi.advanceTimersByTimeAsync(10000);
319
- await p;
320
-
321
- const errLines = console.error.mock.calls.flat().join('\n');
322
- expect(errLines).toContain('Class: container_start_failed');
323
- expect(errLines).toContain('Next: Check the image and command, then retry.');
324
- });
325
- });
326
-
327
- // ─────────────────────────────────────────────────────────────────────────────
328
- // parseTrainArgs + helpers
329
- // ─────────────────────────────────────────────────────────────────────────────
330
-
331
- describe('parseTrainArgs', () => {
332
- it('parses config file positional', () => {
333
- const { configFile } = parseTrainArgs(['config.yaml']);
334
- expect(configFile).toBe('config.yaml');
335
- });
336
-
337
- it('parses --gpu flag', () => {
338
- const { flags } = parseTrainArgs(['config.yaml', '--gpu', 'A100']);
339
- expect(flags.gpu).toBe('A100');
340
- });
341
-
342
- it('parses --max-runtime flag', () => {
343
- const { flags } = parseTrainArgs(['config.yaml', '--max-runtime', '90']);
344
- expect(flags.maxRuntime).toBe(90);
345
- });
346
-
347
- it('parses --max-cost flag', () => {
348
- const { flags } = parseTrainArgs(['config.yaml', '--max-cost', '20']);
349
- expect(flags.maxCost).toBe(20);
350
- });
351
-
352
- it('parses --framework flag', () => {
353
- const { flags } = parseTrainArgs(['config.yaml', '--framework', 'unsloth']);
354
- expect(flags.framework).toBe('unsloth');
355
- });
356
-
357
- it('parses --detach flag', () => {
358
- const { flags } = parseTrainArgs(['config.yaml', '--detach']);
359
- expect(flags.detach).toBe(true);
360
- });
361
-
362
- it('returns null configFile when no positional arg', () => {
363
- const { configFile } = parseTrainArgs(['--gpu', 'A100']);
364
- expect(configFile).toBeNull();
365
- });
366
- });
367
-
368
- describe('detectFramework', () => {
369
- it('detects axolotl from base_model field', () => {
370
- expect(detectFramework('base_model: meta-llama/Llama-2-7b-hf\nsequence_len: 2048')).toBe('axolotl');
371
- });
372
-
373
- it('detects unsloth from unsloth keyword', () => {
374
- expect(detectFramework('# uses unsloth for fast training\nmodel: llama')).toBe('unsloth');
375
- });
376
-
377
- it('detects trl from SFTTrainer', () => {
378
- expect(detectFramework('trainer: SFTTrainer\nmodel: mistral')).toBe('trl');
379
- });
380
-
381
- it('detects trl from DPOTrainer', () => {
382
- expect(detectFramework('trainer: DPOTrainer')).toBe('trl');
383
- });
384
-
385
- it('falls back to generic for unknown config', () => {
386
- expect(detectFramework('some_key: some_value\nother: 123')).toBe('generic');
387
- });
388
- });
389
-
390
- describe('findLocalDatasetPaths', () => {
391
- it('detects local relative paths', () => {
392
- const cfg = 'datasets:\n - path: ./my_data\n type: completion';
393
- expect(findLocalDatasetPaths(cfg)).toContain('./my_data');
394
- });
395
-
396
- it('detects absolute paths', () => {
397
- const cfg = 'datasets:\n - path: /mnt/data/train.jsonl';
398
- expect(findLocalDatasetPaths(cfg)).toContain('/mnt/data/train.jsonl');
399
- });
400
-
401
- it('ignores HuggingFace dataset IDs', () => {
402
- const cfg = 'datasets:\n - path: tatsu-lab/alpaca\n type: alpaca';
403
- expect(findLocalDatasetPaths(cfg)).toEqual([]);
404
- });
405
-
406
- it('ignores s3:// URIs', () => {
407
- const cfg = 'datasets:\n - path: s3://my-bucket/data.jsonl';
408
- expect(findLocalDatasetPaths(cfg)).toEqual([]);
409
- });
410
-
411
- it('ignores https:// URLs', () => {
412
- const cfg = 'datasets:\n - path: https://example.com/data.jsonl';
413
- expect(findLocalDatasetPaths(cfg)).toEqual([]);
414
- });
415
- });
416
-
417
- // ─────────────────────────────────────────────────────────────────────────────
418
- // trainCommand
419
- // ─────────────────────────────────────────────────────────────────────────────
420
-
421
- describe('trainCommand', () => {
422
- it('errors when no config file provided', async () => {
423
- await trainCommand(config, [], chalk);
424
- expect(process.exitCode).toBe(1);
425
- expect(fallback.callWithFallback).not.toHaveBeenCalled();
426
- });
427
-
428
- it('errors when config file does not exist', async () => {
429
- fs.existsSync.mockReturnValue(false);
430
- await trainCommand(config, ['config.yaml'], chalk);
431
- expect(process.exitCode).toBe(1);
432
- expect(fallback.callWithFallback).not.toHaveBeenCalled();
433
- });
434
-
435
- it('launches training job and records receipt', async () => {
436
- const axolotlConfig = 'base_model: meta-llama/Llama-2-7b-hf\nsequence_len: 2048';
437
- fs.existsSync.mockReturnValue(true);
438
- fs.readFileSync.mockReturnValue(axolotlConfig);
439
- fallback.callWithFallback.mockResolvedValue(makeRunDep());
440
-
441
- const p = trainCommand(config, ['config.yaml', '--detach'], chalk);
442
- await vi.advanceTimersByTimeAsync(100);
443
- await p;
444
-
445
- expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({
446
- action: 'badgr train',
447
- deploymentId: 'dep-test-001',
448
- }));
449
- expect(process.exitCode).toBeFalsy();
450
- });
451
-
452
- it('uses axolotl image for axolotl config', async () => {
453
- const axolotlConfig = 'base_model: meta-llama/Llama-2-7b-hf\nsequence_len: 2048';
454
- fs.existsSync.mockReturnValue(true);
455
- fs.readFileSync.mockReturnValue(axolotlConfig);
456
- fallback.callWithFallback.mockResolvedValue(makeRunDep());
457
-
458
- const p = trainCommand(config, ['config.yaml', '--detach'], chalk);
459
- await vi.advanceTimersByTimeAsync(100);
460
- await p;
461
-
462
- const bodyBuilder = fallback.callWithFallback.mock.calls[0][2];
463
- const body = bodyBuilder();
464
- expect(body.image).toBe('winglian/axolotl:main-latest');
465
- });
466
-
467
- it('encodes config as TRAIN_CONFIG_B64', async () => {
468
- const configContent = 'base_model: test-model\n';
469
- fs.existsSync.mockReturnValue(true);
470
- fs.readFileSync.mockReturnValue(configContent);
471
- fallback.callWithFallback.mockResolvedValue(makeRunDep());
472
-
473
- const p = trainCommand(config, ['config.yaml', '--detach'], chalk);
474
- await vi.advanceTimersByTimeAsync(100);
475
- await p;
476
-
477
- const bodyBuilder = fallback.callWithFallback.mock.calls[0][2];
478
- const body = bodyBuilder();
479
- expect(body.env.TRAIN_CONFIG_B64).toBe(Buffer.from(configContent).toString('base64'));
480
- });
481
-
482
- it('applies default 120min max-runtime', async () => {
483
- fs.existsSync.mockReturnValue(true);
484
- fs.readFileSync.mockReturnValue('base_model: test\n');
485
- fallback.callWithFallback.mockResolvedValue(makeRunDep());
486
-
487
- const p = trainCommand(config, ['config.yaml', '--detach'], chalk);
488
- await vi.advanceTimersByTimeAsync(100);
489
- await p;
490
-
491
- const bodyBuilder = fallback.callWithFallback.mock.calls[0][2];
492
- const body = bodyBuilder();
493
- expect(body.max_runtime_seconds).toBe(120 * 60);
494
- });
495
-
496
- it('honours explicit --max-runtime override', async () => {
497
- fs.existsSync.mockReturnValue(true);
498
- fs.readFileSync.mockReturnValue('base_model: test\n');
499
- fallback.callWithFallback.mockResolvedValue(makeRunDep());
500
-
501
- const p = trainCommand(config, ['config.yaml', '--max-runtime', '60', '--detach'], chalk);
502
- await vi.advanceTimersByTimeAsync(100);
503
- await p;
504
-
505
- const bodyBuilder = fallback.callWithFallback.mock.calls[0][2];
506
- const body = bodyBuilder();
507
- expect(body.max_runtime_seconds).toBe(60 * 60);
508
- });
509
-
510
- it('blocks unsloth config instead of running a mismatched command', async () => {
511
- fs.existsSync.mockReturnValue(true);
512
- fs.readFileSync.mockReturnValue('# unsloth training config\nmodel: llama\n');
513
-
514
- await trainCommand(config, ['config.yaml', '--detach'], chalk);
515
-
516
- expect(process.exitCode).toBe(1);
517
- expect(fallback.callWithFallback).not.toHaveBeenCalled();
518
- });
519
-
520
- it('blocks generic (unrecognized) config instead of guessing a command', async () => {
521
- fs.existsSync.mockReturnValue(true);
522
- fs.readFileSync.mockReturnValue('some_key: some_value\nother: 123\n');
523
-
524
- await trainCommand(config, ['config.yaml', '--detach'], chalk);
525
-
526
- expect(process.exitCode).toBe(1);
527
- expect(fallback.callWithFallback).not.toHaveBeenCalled();
528
- });
529
-
530
- it('uses the trl CLI command for a trl-detected config', async () => {
531
- fs.existsSync.mockReturnValue(true);
532
- fs.readFileSync.mockReturnValue('trainer: SFTTrainer\nmodel: mistral\n');
533
- fallback.callWithFallback.mockResolvedValue(makeRunDep());
534
-
535
- const p = trainCommand(config, ['config.yaml', '--detach'], chalk);
536
- await vi.advanceTimersByTimeAsync(100);
537
- await p;
538
-
539
- const bodyBuilder = fallback.callWithFallback.mock.calls[0][2];
540
- const body = bodyBuilder();
541
- expect(body.image).toBe('huggingface/trl-source:latest');
542
- expect(body.command[2]).toContain('trl sft --config /tmp/config.yaml');
543
- });
544
-
545
- it('uses the axolotl CLI command for an axolotl-detected config', async () => {
546
- fs.existsSync.mockReturnValue(true);
547
- fs.readFileSync.mockReturnValue('base_model: meta-llama/Llama-2-7b-hf\nsequence_len: 2048\n');
548
- fallback.callWithFallback.mockResolvedValue(makeRunDep());
549
-
550
- const p = trainCommand(config, ['config.yaml', '--detach'], chalk);
551
- await vi.advanceTimersByTimeAsync(100);
552
- await p;
553
-
554
- const bodyBuilder = fallback.callWithFallback.mock.calls[0][2];
555
- const body = bodyBuilder();
556
- expect(body.command[2]).toContain('axolotl train /tmp/config.yaml');
557
- });
558
- });
559
-
560
- // ─────────────────────────────────────────────────────────────────────────────
561
- // parseTranscribeArgs + resolveAudioInput
562
- // ─────────────────────────────────────────────────────────────────────────────
563
-
564
- describe('parseTranscribeArgs', () => {
565
- it('parses input positional', () => {
566
- const { input } = parseTranscribeArgs(['meeting.mp3']);
567
- expect(input).toBe('meeting.mp3');
568
- });
569
-
570
- it('parses --model flag', () => {
571
- const { flags } = parseTranscribeArgs(['audio.mp3', '--model', 'medium']);
572
- expect(flags.model).toBe('medium');
573
- });
574
-
575
- it('parses --language flag', () => {
576
- const { flags } = parseTranscribeArgs(['audio.mp3', '--language', 'fr']);
577
- expect(flags.language).toBe('fr');
578
- });
579
-
580
- it('parses --max-cost flag', () => {
581
- const { flags } = parseTranscribeArgs(['audio.mp3', '--max-cost', '2']);
582
- expect(flags.maxCost).toBe(2);
583
- });
584
-
585
- it('returns null input for empty args', () => {
586
- const { input } = parseTranscribeArgs([]);
587
- expect(input).toBeNull();
588
- });
589
- });
590
-
591
- describe('resolveAudioInput', () => {
592
- it('accepts https:// URL', () => {
593
- const r = resolveAudioInput('https://example.com/audio.mp3');
594
- expect(r.audioUrl).toBe('https://example.com/audio.mp3');
595
- expect(r.audioB64).toBeNull();
596
- });
597
-
598
- it('accepts s3:// URI', () => {
599
- const r = resolveAudioInput('s3://my-bucket/audio.mp3');
600
- expect(r.audioUrl).toBe('s3://my-bucket/audio.mp3');
601
- });
602
-
603
- it('returns error for missing local file', () => {
604
- fs.existsSync.mockReturnValue(false);
605
- const r = resolveAudioInput('missing.mp3');
606
- expect(r.error).toMatch(/not found/i);
607
- });
608
-
609
- it('returns error for oversized local file', () => {
610
- fs.existsSync.mockReturnValue(true);
611
- fs.statSync.mockReturnValue({ size: 60 * 1024 * 1024 });
612
- const r = resolveAudioInput('big.mp3');
613
- expect(r.error).toMatch(/too large/i);
614
- });
615
-
616
- it('base64-encodes small local file', () => {
617
- fs.existsSync.mockReturnValue(true);
618
- fs.statSync.mockReturnValue({ size: 1024 });
619
- fs.readFileSync.mockReturnValue(Buffer.from('fake-audio-data'));
620
- const r = resolveAudioInput('small.mp3');
621
- expect(r.audioB64).toBeTruthy();
622
- expect(r.audioUrl).toBeNull();
623
- });
624
- });
625
-
626
- // ─────────────────────────────────────────────────────────────────────────────
627
- // transcribeCommand
628
- // ─────────────────────────────────────────────────────────────────────────────
629
-
630
- describe('transcribeCommand', () => {
631
- it('errors when no input provided', async () => {
632
- await transcribeCommand(config, [], chalk);
633
- expect(process.exitCode).toBe(1);
634
- expect(fallback.callWithFallback).not.toHaveBeenCalled();
635
- });
636
-
637
- it('errors for missing local file', async () => {
638
- fs.existsSync.mockReturnValue(false);
639
- await transcribeCommand(config, ['missing.mp3'], chalk);
640
- expect(process.exitCode).toBe(1);
641
- expect(fallback.callWithFallback).not.toHaveBeenCalled();
642
- });
643
-
644
- it('launches transcription job for URL input', async () => {
645
- fallback.callWithFallback.mockResolvedValue(makeRunDep());
646
-
647
- const p = transcribeCommand(
648
- config,
649
- ['https://example.com/audio.mp3', '--detach'],
650
- chalk,
651
- );
652
- await vi.advanceTimersByTimeAsync(100);
653
- await p;
654
-
655
- expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({
656
- action: 'badgr transcribe',
657
- deploymentId: 'dep-test-001',
658
- }));
659
- expect(process.exitCode).toBeFalsy();
660
- });
661
-
662
- it('passes AUDIO_URL env var and uses Whisper image', async () => {
663
- fallback.callWithFallback.mockResolvedValue(makeRunDep());
664
-
665
- const p = transcribeCommand(
666
- config,
667
- ['s3://bucket/audio.mp3', '--detach'],
668
- chalk,
669
- );
670
- await vi.advanceTimersByTimeAsync(100);
671
- await p;
672
-
673
- const bodyBuilder = fallback.callWithFallback.mock.calls[0][2];
674
- const body = bodyBuilder();
675
- expect(body.env.AUDIO_URL).toBe('s3://bucket/audio.mp3');
676
- expect(body.image).toBe('fedirz/faster-whisper-server:latest-cuda');
677
- });
678
-
679
- it('passes AUDIO_B64 env var for small local file', async () => {
680
- fs.existsSync.mockReturnValue(true);
681
- fs.statSync.mockReturnValue({ size: 1024 });
682
- fs.readFileSync.mockReturnValue(Buffer.from('audio-data'));
683
- fallback.callWithFallback.mockResolvedValue(makeRunDep());
684
-
685
- const p = transcribeCommand(config, ['recording.mp3', '--detach'], chalk);
686
- await vi.advanceTimersByTimeAsync(100);
687
- await p;
688
-
689
- const bodyBuilder = fallback.callWithFallback.mock.calls[0][2];
690
- const body = bodyBuilder();
691
- expect(body.env.AUDIO_B64).toBeTruthy();
692
- expect(body.env.AUDIO_URL).toBeUndefined();
693
- });
694
-
695
- it('defaults to large-v3 Whisper model', async () => {
696
- fallback.callWithFallback.mockResolvedValue(makeRunDep());
697
-
698
- const p = transcribeCommand(
699
- config,
700
- ['https://example.com/audio.mp3', '--detach'],
701
- chalk,
702
- );
703
- await vi.advanceTimersByTimeAsync(100);
704
- await p;
705
-
706
- const bodyBuilder = fallback.callWithFallback.mock.calls[0][2];
707
- const body = bodyBuilder();
708
- expect(body.env.WHISPER_MODEL).toBe('large-v3');
709
- });
710
-
711
- it('honours --model override', async () => {
712
- fallback.callWithFallback.mockResolvedValue(makeRunDep());
713
-
714
- const p = transcribeCommand(
715
- config,
716
- ['https://example.com/audio.mp3', '--model', 'medium', '--detach'],
717
- chalk,
718
- );
719
- await vi.advanceTimersByTimeAsync(100);
720
- await p;
721
-
722
- const bodyBuilder = fallback.callWithFallback.mock.calls[0][2];
723
- const body = bodyBuilder();
724
- expect(body.env.WHISPER_MODEL).toBe('medium');
725
- });
726
- });
727
-
728
- // ─────────────────────────────────────────────────────────────────────────────
729
- // parseEmbedArgs + resolveEmbedInput
730
- // ─────────────────────────────────────────────────────────────────────────────
731
-
732
- describe('parseEmbedArgs', () => {
733
- it('parses model + input positional args', () => {
734
- const { model, input } = parseEmbedArgs(['BAAI/bge-large-en-v1.5', 'docs.txt']);
735
- expect(model).toBe('BAAI/bge-large-en-v1.5');
736
- expect(input).toBe('docs.txt');
737
- });
738
-
739
- it('uses default model when only input is given', () => {
740
- const { model, input } = parseEmbedArgs(['docs.txt']);
741
- expect(model).toBe('BAAI/bge-large-en-v1.5');
742
- expect(input).toBe('docs.txt');
743
- });
744
-
745
- it('--model flag takes precedence when only one positional', () => {
746
- const { model, input } = parseEmbedArgs(['--model', 'my-model', 'docs.txt']);
747
- expect(model).toBe('my-model');
748
- expect(input).toBe('docs.txt');
749
- });
750
-
751
- it('parses --max-cost flag', () => {
752
- const { flags } = parseEmbedArgs(['BAAI/bge-large-en-v1.5', 'docs.txt', '--max-cost', '3']);
753
- expect(flags.maxCost).toBe(3);
754
- });
755
-
756
- it('returns null input for empty args', () => {
757
- const { input } = parseEmbedArgs([]);
758
- expect(input).toBeNull();
759
- });
760
- });
761
-
762
- describe('resolveEmbedInput', () => {
763
- it('accepts https:// URL', () => {
764
- const r = resolveEmbedInput('https://example.com/docs.txt');
765
- expect(r.inputUrl).toBe('https://example.com/docs.txt');
766
- expect(r.inputB64).toBeNull();
767
- });
768
-
769
- it('accepts s3:// URI', () => {
770
- const r = resolveEmbedInput('s3://bucket/corpus.jsonl');
771
- expect(r.inputUrl).toBe('s3://bucket/corpus.jsonl');
772
- });
773
-
774
- it('returns error for missing local file', () => {
775
- fs.existsSync.mockReturnValue(false);
776
- const r = resolveEmbedInput('missing.txt');
777
- expect(r.error).toMatch(/not found/i);
778
- });
779
-
780
- it('returns error for oversized file (> 10 MB)', () => {
781
- fs.existsSync.mockReturnValue(true);
782
- fs.statSync.mockReturnValue({ size: 15 * 1024 * 1024 });
783
- const r = resolveEmbedInput('large.txt');
784
- expect(r.error).toMatch(/too large/i);
785
- });
786
-
787
- it('base64-encodes small local file', () => {
788
- fs.existsSync.mockReturnValue(true);
789
- fs.statSync.mockReturnValue({ size: 512 });
790
- fs.readFileSync.mockReturnValue(Buffer.from('line1\nline2\n'));
791
- const r = resolveEmbedInput('docs.txt');
792
- expect(r.inputB64).toBeTruthy();
793
- expect(r.inputUrl).toBeNull();
794
- });
795
- });
796
-
797
- // ─────────────────────────────────────────────────────────────────────────────
798
- // embedCommand
799
- // ─────────────────────────────────────────────────────────────────────────────
800
-
801
- describe('embedCommand', () => {
802
- it('errors when no input provided', async () => {
803
- await embedCommand(config, [], chalk);
804
- expect(process.exitCode).toBe(1);
805
- expect(fallback.callWithFallback).not.toHaveBeenCalled();
806
- });
807
-
808
- it('errors for missing local file', async () => {
809
- fs.existsSync.mockReturnValue(false);
810
- await embedCommand(config, ['BAAI/bge-large-en-v1.5', 'missing.txt'], chalk);
811
- expect(process.exitCode).toBe(1);
812
- expect(fallback.callWithFallback).not.toHaveBeenCalled();
813
- });
814
-
815
- it('launches embeddings job for URL input', async () => {
816
- fallback.callWithFallback.mockResolvedValue(makeRunDep());
817
-
818
- const p = embedCommand(
819
- config,
820
- ['BAAI/bge-large-en-v1.5', 'https://example.com/docs.txt', '--detach'],
821
- chalk,
822
- );
823
- await vi.advanceTimersByTimeAsync(100);
824
- await p;
825
-
826
- expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({
827
- action: 'badgr embed',
828
- deploymentId: 'dep-test-001',
829
- }));
830
- expect(process.exitCode).toBeFalsy();
831
- });
832
-
833
- it('uses vLLM image with VLLM_TASK=embed', async () => {
834
- fallback.callWithFallback.mockResolvedValue(makeRunDep());
835
-
836
- const p = embedCommand(
837
- config,
838
- ['BAAI/bge-large-en-v1.5', 's3://bucket/docs.jsonl', '--detach'],
839
- chalk,
840
- );
841
- await vi.advanceTimersByTimeAsync(100);
842
- await p;
843
-
844
- const bodyBuilder = fallback.callWithFallback.mock.calls[0][2];
845
- const body = bodyBuilder();
846
- expect(body.image).toBe('vllm/vllm-openai:latest');
847
- expect(body.env.VLLM_TASK).toBe('embed');
848
- });
849
-
850
- it('passes model name in env', async () => {
851
- fallback.callWithFallback.mockResolvedValue(makeRunDep());
852
-
853
- const p = embedCommand(
854
- config,
855
- ['my-org/my-embed-model', 's3://bucket/docs.jsonl', '--detach'],
856
- chalk,
857
- );
858
- await vi.advanceTimersByTimeAsync(100);
859
- await p;
860
-
861
- const bodyBuilder = fallback.callWithFallback.mock.calls[0][2];
862
- const body = bodyBuilder();
863
- expect(body.env.EMBED_MODEL).toBe('my-org/my-embed-model');
864
- });
865
- });