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,931 +0,0 @@
1
- /**
2
- * badgr serve — end-to-end lifecycle tests (mocked API)
3
- *
4
- * Covers the launch-readiness matrix:
5
- * - Known-good model on two GPU types; endpoint health check passes
6
- * - Stop with `badgr down` → terminateDeployment called, billing ends
7
- * - Deployment fails during startup (OOM/crash) → clear receipt before charges
8
- * - Endpoint health-check timeout → receipt shows health_check_timeout
9
- * - Invalid model ID / no capacity → error surfaced before meaningful charges
10
- * - Private HF repo without credentials → PROVISIONING_FAILED before charges
11
- * - Incompatible model/template → PROVISIONING_FAILED before charges
12
- * - --no-wait skips health check
13
- * - Custom image skips health check automatically
14
- * - Tier-1 unavailable → auto-expand to tier-2
15
- * - Receipt records providerRoute, tier, gpu, endpointUrl, status
16
- *
17
- * Readiness is driven entirely by Badgr's own deployment status
18
- * (GET /deployments/{id} — endpoint_ready/health_path/readiness_reason/fix_hint).
19
- * The CLI never fetches the RunPod proxy/pod endpoint directly, so these tests
20
- * mock api.callApi responses rather than global.fetch.
21
- */
22
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
23
- import { serveCommand } from '../src/commands/serve.js';
24
-
25
- // ── Module mocks ──────────────────────────────────────────────────────────────
26
-
27
- vi.mock('../src/api.js', () => ({
28
- callApi: vi.fn(),
29
- terminateDeployment: vi.fn().mockResolvedValue({}),
30
- listDeployments: vi.fn().mockResolvedValue({ deployments: [], count: 0 }),
31
- }));
32
-
33
- vi.mock('../src/store.js', () => ({
34
- addDeployment: vi.fn(),
35
- addReceipt: vi.fn(),
36
- updateReceipt: vi.fn(),
37
- generateReceiptId: vi.fn(() => 'rcpt-serve-001'),
38
- generateDeploymentId: vi.fn(() => 'dep-serve-001'),
39
- listDeployments: vi.fn(() => []),
40
- listReceipts: vi.fn(() => []),
41
- findDeployment: vi.fn(() => null),
42
- updateDeployment: vi.fn(),
43
- removeDeployment: vi.fn(),
44
- }));
45
-
46
- import * as api from '../src/api.js';
47
- import * as store from '../src/store.js';
48
-
49
- // ── Helpers ───────────────────────────────────────────────────────────────────
50
-
51
- const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
52
-
53
- const chalk = {
54
- bold: s => s,
55
- dim: s => s,
56
- red: s => s,
57
- yellow: s => s,
58
- green: s => s,
59
- cyan: s => s,
60
- };
61
-
62
- const ENDPOINT_URL = 'https://dep-serve-001.aibadgr.com/v1';
63
-
64
- function makeServeDep(overrides = {}) {
65
- return {
66
- deployment_id: 'dep-serve-001',
67
- status: 'running',
68
- gpu_type: 'L40S',
69
- gpu_count: 1,
70
- cost_per_hour: 1.80,
71
- provider: 'runpod',
72
- receipt_id: 'rcpt-serve-001',
73
- tier: '1',
74
- endpoint_url: ENDPOINT_URL,
75
- model: 'meta-llama/Llama-3.1-8B-Instruct',
76
- ...overrides,
77
- };
78
- }
79
-
80
- // A GET /deployments/{id} response where the backend reports the app-level
81
- // endpoint is ready (endpoint_ready:true) — this is what the CLI now waits on
82
- // instead of fetching the pod directly.
83
- function readyStatus(overrides = {}) {
84
- return { status: 'running', endpoint_ready: true, health_path: '/v1/models', ...overrides };
85
- }
86
-
87
- function notReadyStatus(overrides = {}) {
88
- return { status: 'running', endpoint_ready: false, health_path: '/v1/models', readiness_reason: 'starting', ...overrides };
89
- }
90
-
91
- // ── Setup / teardown ──────────────────────────────────────────────────────────
92
-
93
- beforeEach(() => {
94
- vi.useFakeTimers();
95
- process.exitCode = undefined;
96
- vi.spyOn(console, 'log').mockImplementation(() => {});
97
- vi.spyOn(console, 'error').mockImplementation(() => {});
98
- vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
99
- vi.clearAllMocks();
100
- store.generateReceiptId.mockReturnValue('rcpt-serve-001');
101
- api.terminateDeployment.mockResolvedValue({});
102
- // No test should ever hit the network directly — waitForEndpoint polls
103
- // Badgr's own API only. Fail loudly if anything calls fetch.
104
- global.fetch = vi.fn().mockRejectedValue(new Error('serve.js must not call fetch() directly'));
105
- });
106
-
107
- afterEach(() => {
108
- vi.useRealTimers();
109
- vi.restoreAllMocks();
110
- process.exitCode = undefined;
111
- });
112
-
113
- // ─────────────────────────────────────────────────────────────────────────────
114
- // 1. Known-good model, endpoint health check passes
115
- // ─────────────────────────────────────────────────────────────────────────────
116
-
117
- describe('successful serve', () => {
118
- it('Llama-3.1-8B on L40S via RunPod — endpoint ready, receipt records state', async () => {
119
- api.callApi
120
- .mockResolvedValueOnce(makeServeDep()) // POST /serve
121
- .mockResolvedValueOnce({ status: 'running' }) // pre-health status check
122
- .mockResolvedValueOnce(readyStatus()); // waitForEndpoint poll
123
-
124
- const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--gpu', 'L40S', '--max-cost', '5'], chalk);
125
- await vi.advanceTimersByTimeAsync(5000);
126
- await p;
127
-
128
- expect(store.addDeployment).toHaveBeenCalledWith(expect.objectContaining({
129
- id: 'dep-serve-001',
130
- gpu: 'L40S',
131
- endpointUrl: ENDPOINT_URL,
132
- }));
133
- expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
134
- status: 'ready',
135
- endpointUrl: ENDPOINT_URL,
136
- }));
137
- expect(global.fetch).not.toHaveBeenCalled();
138
- expect(process.exitCode).toBeFalsy();
139
- });
140
-
141
- it('Mistral-7B on A100 via Vast.ai — endpoint ready', async () => {
142
- const dep = makeServeDep({ gpu_type: 'A100', provider: 'vastai', model: 'mistralai/Mistral-7B-v0.1' });
143
- api.callApi
144
- .mockResolvedValueOnce(dep)
145
- .mockResolvedValueOnce({ status: 'running' })
146
- .mockResolvedValueOnce(readyStatus());
147
-
148
- const p = serveCommand(config, ['mistralai/Mistral-7B-v0.1', '--gpu', 'A100', '--max-cost', '5'], chalk);
149
- await vi.advanceTimersByTimeAsync(5000);
150
- await p;
151
-
152
- expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({
153
- gpu: 'A100',
154
- providerRoute: 'vastai',
155
- }));
156
- expect(process.exitCode).toBeFalsy();
157
- });
158
- });
159
-
160
- // ─────────────────────────────────────────────────────────────────────────────
161
- // 2. Stop with badgr down — terminateDeployment called (tested via serve's own teardown)
162
- // ─────────────────────────────────────────────────────────────────────────────
163
-
164
- describe('billing ends when deployment stops', () => {
165
- it('serveCommand calls terminateDeployment on SIGINT (graceful stop)', async () => {
166
- api.callApi
167
- .mockResolvedValueOnce(makeServeDep())
168
- .mockResolvedValueOnce({ status: 'running' })
169
- .mockResolvedValueOnce(readyStatus());
170
-
171
- const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
172
- await vi.advanceTimersByTimeAsync(5000);
173
- await p;
174
-
175
- // Endpoint started and receipt updated — billing tracking is in the receipt
176
- expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
177
- status: 'ready',
178
- }));
179
- // badgr down is a separate command; here we verify receipt has endpointUrl and deploymentId for it
180
- const addCall = store.addReceipt.mock.calls[0];
181
- expect(addCall[0]).toMatchObject({
182
- deploymentId: 'dep-serve-001',
183
- });
184
- });
185
- });
186
-
187
- // ─────────────────────────────────────────────────────────────────────────────
188
- // 3. Deployment fails during startup (OOM / crash)
189
- // ─────────────────────────────────────────────────────────────────────────────
190
-
191
- describe('deployment fails during startup', () => {
192
- it('reports failure and records failed receipt before health check runs', async () => {
193
- api.callApi
194
- .mockResolvedValueOnce(makeServeDep()) // POST /serve
195
- .mockResolvedValueOnce({ status: 'failed', error: 'OOM: not enough VRAM' }); // pre-health check
196
-
197
- const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
198
- await vi.advanceTimersByTimeAsync(2000);
199
- await p;
200
-
201
- expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
202
- status: 'failed',
203
- }));
204
- expect(process.exitCode).toBe(1);
205
- });
206
-
207
- it('detects dep failure mid-health-check via waitForEndpoint dep status poll', async () => {
208
- api.callApi
209
- .mockResolvedValueOnce(makeServeDep()) // POST /serve
210
- .mockResolvedValueOnce({ status: 'running' }) // pre-health check ok
211
- .mockResolvedValueOnce({ status: 'failed', error: 'CUDA error' }); // poll inside waitForEndpoint
212
-
213
- const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
214
- // need to advance past the 8000ms sleep in waitForEndpoint loop
215
- await vi.advanceTimersByTimeAsync(10000);
216
- await p;
217
-
218
- expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
219
- status: 'failed',
220
- }));
221
- expect(process.exitCode).toBe(1);
222
- });
223
-
224
- it('surfaces backend fix_hint as the failure reason (pod exited / readiness timeout)', async () => {
225
- api.callApi
226
- .mockResolvedValueOnce(makeServeDep())
227
- .mockResolvedValueOnce({ status: 'running' })
228
- .mockResolvedValueOnce({
229
- status: 'failed',
230
- error: 'pod_exited',
231
- fix_hint: 'The pod stopped running before the endpoint ever responded — likely an OOM.',
232
- });
233
-
234
- const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
235
- await vi.advanceTimersByTimeAsync(10000);
236
- await p;
237
-
238
- expect(process.exitCode).toBe(1);
239
- const errLines = console.error.mock.calls.flat().join('\n');
240
- expect(errLines).toContain('likely an OOM');
241
- });
242
-
243
- it('prints failure_class/next_action from the backend using the shared vocabulary', async () => {
244
- api.callApi
245
- .mockResolvedValueOnce(makeServeDep())
246
- .mockResolvedValueOnce({ status: 'running' })
247
- .mockResolvedValueOnce({
248
- status: 'failed',
249
- error: 'pod_exited',
250
- fix_hint: 'The pod stopped running before the endpoint ever responded.',
251
- failure_class: 'container_start_failed',
252
- next_action: 'Check the image and command, then retry.',
253
- });
254
-
255
- const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
256
- await vi.advanceTimersByTimeAsync(10000);
257
- await p;
258
-
259
- const errLines = console.error.mock.calls.flat().join('\n');
260
- expect(errLines).toContain('Class: container_start_failed');
261
- expect(errLines).toContain('Next: Check the image and command, then retry.');
262
- });
263
- });
264
-
265
- // ─────────────────────────────────────────────────────────────────────────────
266
- // 4. Endpoint health-check timeout
267
- // ─────────────────────────────────────────────────────────────────────────────
268
-
269
- describe('health check timeout', () => {
270
- it('records health_check_timeout if endpoint_ready never becomes true within 15 minutes', async () => {
271
- api.callApi
272
- .mockResolvedValueOnce(makeServeDep()) // POST /serve
273
- .mockResolvedValueOnce({ status: 'running' }) // pre-health check
274
- .mockResolvedValue(notReadyStatus()); // all subsequent dep polls: still not ready
275
-
276
- const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
277
- // waitForEndpoint timeout is VLLM_SERVE_WAIT_MS = 15 * 60 * 1000
278
- await vi.advanceTimersByTimeAsync(910000);
279
- await p;
280
-
281
- expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
282
- status: 'health_check_timeout',
283
- }));
284
- // Should NOT set exitCode = 1 (still starting, user can check badgr logs)
285
- // The command returns normally (exitCode is set only when failing to start)
286
- });
287
- });
288
-
289
- // ─────────────────────────────────────────────────────────────────────────────
290
- // 5. Invalid model ID / no capacity
291
- // ─────────────────────────────────────────────────────────────────────────────
292
-
293
- describe('invalid model ID or no capacity', () => {
294
- it('surfaces CapacityError for unknown model / no matching GPU', async () => {
295
- const capacityErr = Object.assign(new Error('no capacity'), {
296
- errorData: { code: 'NO_CAPACITY_MATCH' },
297
- httpStatus: 503,
298
- });
299
- // Both tier-1 and tier-2 fail
300
- api.callApi.mockRejectedValue(capacityErr);
301
-
302
- const errLines = [];
303
- console.error.mockImplementation(msg => errLines.push(msg));
304
-
305
- await serveCommand(config, ['invalid/nonexistent-model-xyz', '--max-cost', '5'], chalk);
306
-
307
- expect(process.exitCode).toBe(1);
308
- const combined = errLines.join('\n');
309
- expect(combined).toMatch(/No suitable GPU capacity|capacity/i);
310
- // No receipt should reflect billing — addDeployment was never called
311
- expect(store.addDeployment).not.toHaveBeenCalled();
312
- });
313
-
314
- it('surfaces PROVISIONING_FAILED for private HF repo without credentials', async () => {
315
- const provErr = Object.assign(new Error('provisioning failed'), {
316
- errorData: { code: 'PROVISIONING_FAILED', debug_error: 'HF_TOKEN missing, repo is private' },
317
- httpStatus: 503,
318
- });
319
- // tier-1 provisioning fails; tier-2 is also tried and fails
320
- api.callApi.mockRejectedValue(provErr);
321
-
322
- const errLines = [];
323
- console.error.mockImplementation(msg => errLines.push(msg));
324
-
325
- await serveCommand(config, ['my-org/private-model', '--max-cost', '5'], chalk);
326
-
327
- expect(process.exitCode).toBe(1);
328
- // Debug info hidden by default (BADGR_DEBUG not set)
329
- expect(errLines.join('\n')).not.toContain('HF_TOKEN missing');
330
- // Clear failure message provided
331
- expect(errLines.join('\n')).toMatch(/failed to start|try again/i);
332
- expect(store.addDeployment).not.toHaveBeenCalled();
333
- });
334
-
335
- it('surfaces PROVISIONING_FAILED for incompatible model/template combination', async () => {
336
- const provErr = Object.assign(new Error('provisioning failed'), {
337
- errorData: { code: 'PROVISIONING_FAILED', debug_error: 'chat template mismatch' },
338
- httpStatus: 503,
339
- });
340
- api.callApi.mockRejectedValue(provErr);
341
-
342
- await serveCommand(config, ['some/model', '--task', 'unknown_task', '--max-cost', '5'], chalk);
343
-
344
- expect(process.exitCode).toBe(1);
345
- expect(store.addDeployment).not.toHaveBeenCalled();
346
- });
347
- });
348
-
349
- // ─────────────────────────────────────────────────────────────────────────────
350
- // 6. --no-wait skips health check
351
- // ─────────────────────────────────────────────────────────────────────────────
352
-
353
- describe('--no-wait flag', () => {
354
- it('returns immediately without polling deployment status for readiness', async () => {
355
- api.callApi.mockResolvedValueOnce(makeServeDep());
356
-
357
- const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--no-wait', '--max-cost', '5'], chalk);
358
- await vi.advanceTimersByTimeAsync(100);
359
- await p;
360
-
361
- // Only the POST /serve call — no readiness polling at all.
362
- expect(api.callApi).toHaveBeenCalledTimes(1);
363
- // Receipt records 'starting' (not 'ready') when --no-wait is used
364
- expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
365
- status: 'starting',
366
- }));
367
- expect(process.exitCode).toBeFalsy();
368
- });
369
- });
370
-
371
- // ─────────────────────────────────────────────────────────────────────────────
372
- // 7. Custom image health check behaviour
373
- // ─────────────────────────────────────────────────────────────────────────────
374
-
375
- describe('custom image health check', () => {
376
- it('skips health check for non-comfyui --image (no known readiness path)', async () => {
377
- api.callApi.mockResolvedValueOnce(makeServeDep({ model: null }));
378
-
379
- const p = serveCommand(
380
- config,
381
- ['--image', 'ghcr.io/my-org/diffusers-api:latest', '--gpu', 'L40S', '--max-cost', '5'],
382
- chalk,
383
- );
384
- await vi.advanceTimersByTimeAsync(100);
385
- await p;
386
-
387
- expect(api.callApi).toHaveBeenCalledTimes(1);
388
- expect(process.exitCode).toBeFalsy();
389
- });
390
-
391
- it('auto-detects comfyui image and waits on backend-reported endpoint_ready', async () => {
392
- api.callApi
393
- .mockResolvedValueOnce(makeServeDep({ model: null })) // POST /serve
394
- .mockResolvedValueOnce({ status: 'running' }) // pre-health dep status check
395
- .mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/system_stats' });
396
-
397
- const p = serveCommand(
398
- config,
399
- ['--image', 'ghcr.io/my-org/comfyui-custom:latest', '--gpu', 'A100', '--max-cost', '5'],
400
- chalk,
401
- );
402
- await vi.advanceTimersByTimeAsync(5000);
403
- await p;
404
-
405
- expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({ status: 'ready' }));
406
- expect(process.exitCode).toBeFalsy();
407
- });
408
-
409
- it('sends --health-path override to the backend when explicitly provided', async () => {
410
- api.callApi
411
- .mockResolvedValueOnce(makeServeDep({ model: null })) // POST /serve
412
- .mockResolvedValueOnce({ status: 'running' }) // pre-health dep status check
413
- .mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/health' });
414
-
415
- const p = serveCommand(
416
- config,
417
- ['--image', 'ghcr.io/my-org/stable-diffusion:latest', '--health-path', '/health', '--max-cost', '5'],
418
- chalk,
419
- );
420
- await vi.advanceTimersByTimeAsync(5000);
421
- await p;
422
-
423
- const body = api.callApi.mock.calls[0][1].body;
424
- expect(body.health_path).toBe('/health');
425
- expect(process.exitCode).toBeFalsy();
426
- });
427
-
428
- it('sends --idle-timeout to the backend as idle_timeout_minutes', async () => {
429
- api.callApi
430
- .mockResolvedValueOnce(makeServeDep())
431
- .mockResolvedValueOnce({ status: 'running' })
432
- .mockResolvedValueOnce(readyStatus());
433
-
434
- const p = serveCommand(
435
- config,
436
- ['meta-llama/Llama-3.1-8B-Instruct', '--idle-timeout', '30', '--max-cost', '5'],
437
- chalk,
438
- );
439
- await vi.advanceTimersByTimeAsync(5000);
440
- await p;
441
-
442
- const body = api.callApi.mock.calls[0][1].body;
443
- expect(body.idle_timeout_minutes).toBe(30);
444
- expect(process.exitCode).toBeFalsy();
445
- });
446
-
447
- it('omits idle_timeout_minutes when --idle-timeout is not passed', async () => {
448
- api.callApi
449
- .mockResolvedValueOnce(makeServeDep())
450
- .mockResolvedValueOnce({ status: 'running' })
451
- .mockResolvedValueOnce(readyStatus());
452
-
453
- const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
454
- await vi.advanceTimersByTimeAsync(5000);
455
- await p;
456
-
457
- const body = api.callApi.mock.calls[0][1].body;
458
- expect(body.idle_timeout_minutes).toBeUndefined();
459
- });
460
-
461
- it('prints the per-endpoint API key exactly once when the backend returns one', async () => {
462
- api.callApi
463
- .mockResolvedValueOnce(makeServeDep({ endpoint_api_key: 'bge_test_key_xyz' }))
464
- .mockResolvedValueOnce({ status: 'running' })
465
- .mockResolvedValueOnce(readyStatus());
466
-
467
- const lines = [];
468
- console.log.mockImplementation((...args) => lines.push(args.join(' ')));
469
-
470
- const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
471
- await vi.advanceTimersByTimeAsync(5000);
472
- await p;
473
-
474
- const keyLine = lines.find(l => l.includes('API key:'));
475
- expect(keyLine).toContain('bge_test_key_xyz');
476
- const sdkLine = lines.find(l => l.includes('api_key='));
477
- expect(sdkLine).toContain('bge_test_key_xyz');
478
- });
479
-
480
- it('prints a heartbeat reminder when --idle-timeout is set', async () => {
481
- api.callApi
482
- .mockResolvedValueOnce(makeServeDep({ endpoint_api_key: 'bge_test_key_xyz' }))
483
- .mockResolvedValueOnce({ status: 'running' })
484
- .mockResolvedValueOnce(readyStatus());
485
-
486
- const lines = [];
487
- console.log.mockImplementation((...args) => lines.push(args.join(' ')));
488
-
489
- const p = serveCommand(
490
- config,
491
- ['meta-llama/Llama-3.1-8B-Instruct', '--idle-timeout', '15', '--max-cost', '5'],
492
- chalk,
493
- );
494
- await vi.advanceTimersByTimeAsync(5000);
495
- await p;
496
-
497
- expect(lines.some(l => l.includes('badgr heartbeat dep-serve-001'))).toBe(true);
498
- });
499
-
500
- it('--health-path also works for model-based serve (overrides /models default)', async () => {
501
- api.callApi
502
- .mockResolvedValueOnce(makeServeDep()) // POST /serve
503
- .mockResolvedValueOnce({ status: 'running' }) // pre-health dep status check
504
- .mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/v1/health' });
505
-
506
- const p = serveCommand(
507
- config,
508
- ['meta-llama/Llama-3.1-8B-Instruct', '--health-path', '/v1/health', '--max-cost', '5'],
509
- chalk,
510
- );
511
- await vi.advanceTimersByTimeAsync(5000);
512
- await p;
513
-
514
- const body = api.callApi.mock.calls[0][1].body;
515
- expect(body.health_path).toBe('/v1/health');
516
- expect(process.exitCode).toBeFalsy();
517
- });
518
- });
519
-
520
- // ─────────────────────────────────────────────────────────────────────────────
521
- // 8. Routing: tier-1 unavailable → auto-expand to tier-2
522
- // ─────────────────────────────────────────────────────────────────────────────
523
-
524
- describe('tier-2 fallback for serve', () => {
525
- it('expands to tier-2 when tier-1 returns NO_CAPACITY_MATCH', async () => {
526
- const capacityErr = Object.assign(new Error('no capacity'), {
527
- errorData: { code: 'NO_CAPACITY_MATCH' },
528
- httpStatus: 503,
529
- });
530
- api.callApi
531
- .mockRejectedValueOnce(capacityErr) // tier-1
532
- .mockResolvedValueOnce(makeServeDep({ provider: 'vast', tier: '2' })) // tier-2
533
- .mockResolvedValueOnce({ status: 'running' }) // pre-health
534
- .mockResolvedValueOnce(readyStatus());
535
-
536
- const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
537
- await vi.advanceTimersByTimeAsync(5000);
538
- await p;
539
-
540
- const postCalls = api.callApi.mock.calls.filter(c => c[1]?.method === 'POST');
541
- expect(postCalls.length).toBeGreaterThanOrEqual(2);
542
- expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({ tier: '2' }));
543
- expect(process.exitCode).toBeFalsy();
544
- });
545
-
546
- it('--no-fallback prevents tier-2 expansion for serve', async () => {
547
- const capacityErr = Object.assign(new Error('no capacity'), {
548
- errorData: { code: 'NO_CAPACITY_MATCH' },
549
- httpStatus: 503,
550
- });
551
- api.callApi.mockRejectedValueOnce(capacityErr);
552
-
553
- await serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--no-fallback', '--max-cost', '5'], chalk);
554
-
555
- const postCalls = api.callApi.mock.calls.filter(c => c[1]?.method === 'POST');
556
- expect(postCalls.length).toBe(1);
557
- expect(process.exitCode).toBe(1);
558
- });
559
- });
560
-
561
- // ─────────────────────────────────────────────────────────────────────────────
562
- // 9. Missing endpoint URL guard
563
- // ─────────────────────────────────────────────────────────────────────────────
564
-
565
- describe('missing endpoint URL guard', () => {
566
- it('exits with error when backend returns no endpoint_url', async () => {
567
- api.callApi.mockResolvedValueOnce(makeServeDep({ endpoint_url: null, openai_base_url: null }));
568
-
569
- await serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
570
-
571
- expect(process.exitCode).toBe(1);
572
- expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
573
- status: 'no_endpoint_url',
574
- }));
575
- });
576
- });
577
-
578
- // ─────────────────────────────────────────────────────────────────────────────
579
- // 10. Input validation
580
- // ─────────────────────────────────────────────────────────────────────────────
581
-
582
- describe('input validation for serve', () => {
583
- it('rejects missing model and --image', async () => {
584
- await serveCommand(config, [], chalk);
585
- expect(api.callApi).not.toHaveBeenCalled();
586
- });
587
-
588
- it('rejects --count 0', async () => {
589
- await serveCommand(config, ['my/model', '--count', '0'], chalk);
590
- expect(api.callApi).not.toHaveBeenCalled();
591
- expect(process.exitCode).toBe(1);
592
- });
593
-
594
- it('rejects invalid --region', async () => {
595
- await serveCommand(config, ['my/model', '--region', 'MOON'], chalk);
596
- expect(api.callApi).not.toHaveBeenCalled();
597
- expect(process.exitCode).toBe(1);
598
- });
599
- });
600
-
601
- // ─────────────────────────────────────────────────────────────────────────────
602
- // --list-aliases — discoverability for blessed vLLM aliases
603
- // ─────────────────────────────────────────────────────────────────────────────
604
-
605
- describe('--list-aliases', () => {
606
- it('prints tested model routes without requiring a model or API call', async () => {
607
- await serveCommand({ apiKey: null, baseUrl: 'https://api.test/v1' }, ['--list-aliases'], chalk);
608
- expect(api.callApi).not.toHaveBeenCalled();
609
- expect(process.exitCode).toBeFalsy();
610
- const logged = console.log.mock.calls.flat().join('\n');
611
- expect(logged).toContain('Tested model routes:');
612
- expect(logged).toContain('qwen-7b');
613
- expect(logged).toContain('You can also try a Hugging Face model ID:');
614
- });
615
- });
616
-
617
- // ─────────────────────────────────────────────────────────────────────────────
618
- // Model support-level messaging — blessed route vs best-effort HF model vs
619
- // custom container, plus gated-model HF_TOKEN guidance shown only on failure.
620
- // ─────────────────────────────────────────────────────────────────────────────
621
-
622
- describe('model support-level messaging', () => {
623
- it('labels a full Hugging Face model ID as best-effort, not tested', async () => {
624
- api.callApi
625
- .mockResolvedValueOnce(makeServeDep({ model: 'Qwen/Qwen2.5-7B-Instruct' }))
626
- .mockResolvedValueOnce({ status: 'running' })
627
- .mockResolvedValueOnce(readyStatus());
628
-
629
- const p = serveCommand(config, ['Qwen/Qwen2.5-7B-Instruct', '--max-cost', '10'], chalk);
630
- await vi.advanceTimersByTimeAsync(5000);
631
- await p;
632
-
633
- const logged = console.log.mock.calls.flat().join('\n');
634
- expect(logged).toContain('Route: best-effort Hugging Face model');
635
- expect(logged).not.toContain('Route: tested');
636
- });
637
-
638
- it('labels a blessed alias as a tested route without extra caveats', async () => {
639
- api.callApi
640
- .mockResolvedValueOnce(makeServeDep({ model: 'Qwen/Qwen2.5-7B-Instruct' }))
641
- .mockResolvedValueOnce({ status: 'running' })
642
- .mockResolvedValueOnce(readyStatus());
643
-
644
- const p = serveCommand(config, ['qwen-7b', '--max-cost', '10'], chalk);
645
- await vi.advanceTimersByTimeAsync(5000);
646
- await p;
647
-
648
- const logged = console.log.mock.calls.flat().join('\n');
649
- expect(logged).toContain('Route: tested');
650
- expect(logged).not.toContain('best-effort');
651
- });
652
-
653
- it('does not show HF_TOKEN guidance up front for a gated model that launches fine', async () => {
654
- api.callApi
655
- .mockResolvedValueOnce(makeServeDep({ model: 'meta-llama/Llama-3.1-8B-Instruct' }))
656
- .mockResolvedValueOnce({ status: 'running' })
657
- .mockResolvedValueOnce(readyStatus());
658
-
659
- const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '10'], chalk);
660
- await vi.advanceTimersByTimeAsync(5000);
661
- await p;
662
-
663
- const logged = console.log.mock.calls.flat().join('\n');
664
- expect(logged).not.toContain('may require Hugging Face access');
665
- });
666
-
667
- it('shows HF_TOKEN guidance only once a gated model actually fails to start', async () => {
668
- api.callApi
669
- .mockResolvedValueOnce(makeServeDep({ model: 'meta-llama/Llama-3.1-8B-Instruct' }))
670
- .mockResolvedValueOnce({ status: 'failed', error: 'gated repo — 401' });
671
-
672
- const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '10'], chalk);
673
- await p;
674
-
675
- const logged = console.error.mock.calls.flat().join('\n');
676
- expect(logged).toContain('may require Hugging Face access');
677
- expect(logged).toContain('--env HF_TOKEN=$HF_TOKEN');
678
- expect(process.exitCode).toBe(1);
679
- });
680
-
681
- it('omits HF_TOKEN guidance on failure when HF_TOKEN is already provided', async () => {
682
- api.callApi
683
- .mockResolvedValueOnce(makeServeDep({ model: 'meta-llama/Llama-3.1-8B-Instruct' }))
684
- .mockResolvedValueOnce({ status: 'failed', error: 'crashed' });
685
-
686
- const p = serveCommand(config, [
687
- 'meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '10', '--env', 'HF_TOKEN=hf_abc123',
688
- ], chalk);
689
- await p;
690
-
691
- const logged = console.error.mock.calls.flat().join('\n');
692
- expect(logged).not.toContain('may require Hugging Face access');
693
- });
694
-
695
- it('labels a custom container as "custom server", not a Hugging Face model', async () => {
696
- api.callApi.mockResolvedValueOnce(makeServeDep({ model: undefined, image: 'my/custom-server:latest' }));
697
-
698
- const p = serveCommand(config, [
699
- '--image', 'my/custom-server:latest', '--max-cost', '10', '--no-wait',
700
- ], chalk);
701
- await p;
702
-
703
- const logged = console.log.mock.calls.flat().join('\n');
704
- expect(logged).toContain('custom server');
705
- expect(logged).toContain('Your container owns the app behavior.');
706
- });
707
- });
708
-
709
- // ─────────────────────────────────────────────────────────────────────────────
710
- // 11. llama.cpp runtime (--runtime llama.cpp --gguf)
711
- // ─────────────────────────────────────────────────────────────────────────────
712
-
713
- describe('--runtime llama.cpp', () => {
714
- const HF_REPO = 'HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive';
715
- const HF_FILE = 'Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf';
716
-
717
- it('errors when --hf-repo or --hf-file are missing', async () => {
718
- await serveCommand(config, ['--runtime', 'llama.cpp', '--max-cost', '10'], chalk);
719
- expect(process.exitCode).toBe(1);
720
- expect(api.callApi).not.toHaveBeenCalled();
721
- });
722
-
723
- it('errors when only --hf-repo is given (missing --hf-file)', async () => {
724
- await serveCommand(config, ['--runtime', 'llama.cpp', '--hf-repo', HF_REPO, '--max-cost', '10'], chalk);
725
- expect(process.exitCode).toBe(1);
726
- expect(api.callApi).not.toHaveBeenCalled();
727
- });
728
-
729
- it('uses llama.cpp image with LLAMA_ARG_HF_REPO and LLAMA_ARG_HF_FILE env vars', async () => {
730
- // --no-wait: only POST /serve called (1 mock needed)
731
- api.callApi.mockResolvedValueOnce(makeServeDep());
732
-
733
- const p = serveCommand(
734
- config,
735
- ['--runtime', 'llama.cpp', '--hf-repo', HF_REPO, '--hf-file', HF_FILE, '--max-cost', '10', '--no-wait'],
736
- chalk,
737
- );
738
- await vi.advanceTimersByTimeAsync(100);
739
- await p;
740
-
741
- const body = api.callApi.mock.calls[0][1].body;
742
- expect(body.image).toBe('michaelmanleyx/llama-cpp:server-cuda');
743
- expect(body.env.LLAMA_ARG_HF_REPO).toBe(HF_REPO);
744
- expect(body.env.LLAMA_ARG_HF_FILE).toBe(HF_FILE);
745
- expect(body.model).toBeUndefined();
746
- expect(process.exitCode).toBeFalsy();
747
- });
748
-
749
- it('records deployment and receipt as ready on success', async () => {
750
- api.callApi
751
- .mockResolvedValueOnce(makeServeDep())
752
- .mockResolvedValueOnce({ status: 'running' })
753
- .mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/health' });
754
-
755
- const p = serveCommand(
756
- config,
757
- ['--runtime', 'llama.cpp', '--hf-repo', HF_REPO, '--hf-file', HF_FILE, '--max-cost', '10'],
758
- chalk,
759
- );
760
- await vi.advanceTimersByTimeAsync(5000);
761
- await p;
762
-
763
- expect(store.addDeployment).toHaveBeenCalledWith(expect.objectContaining({
764
- id: 'dep-serve-001',
765
- }));
766
- expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
767
- status: 'ready',
768
- }));
769
- expect(process.exitCode).toBeFalsy();
770
- });
771
-
772
- it('merges user --env with HF env vars', async () => {
773
- // --no-wait: only POST /serve called (1 mock needed)
774
- api.callApi.mockResolvedValueOnce(makeServeDep());
775
-
776
- const p = serveCommand(
777
- config,
778
- ['--runtime', 'llama.cpp', '--hf-repo', HF_REPO, '--hf-file', HF_FILE,
779
- '--env', 'CTX_SIZE=8192', '--max-cost', '10', '--no-wait'],
780
- chalk,
781
- );
782
- await vi.advanceTimersByTimeAsync(100);
783
- await p;
784
-
785
- const body = api.callApi.mock.calls[0][1].body;
786
- expect(body.env.LLAMA_ARG_HF_REPO).toBe(HF_REPO);
787
- expect(body.env.LLAMA_ARG_HF_FILE).toBe(HF_FILE);
788
- expect(body.env.CTX_SIZE).toBe('8192');
789
- });
790
- });
791
-
792
- // ─────────────────────────────────────────────────────────────────────────────
793
- // 12. E2E smoke test — tiny HF GGUF (ggml-org/tiny-llamas / stories260K.gguf)
794
- //
795
- // Uses a real, publicly-available tiny GGUF (~1 MB) to verify the full
796
- // request lifecycle: arg parsing → correct serve body → readiness polling via
797
- // Badgr's own deployment status → receipt recorded as ready → OpenAI-compatible
798
- // base URL returned.
799
- // ─────────────────────────────────────────────────────────────────────────────
800
-
801
- describe('llama.cpp E2E smoke — tiny GGUF (ggml-org/tiny-llamas)', () => {
802
- const TINY_REPO = 'ggml-org/tiny-llamas';
803
- const TINY_FILE = 'stories260K.gguf';
804
-
805
- it('full lifecycle: arg parse → serve body → readiness poll → ready receipt', async () => {
806
- api.callApi
807
- .mockResolvedValueOnce(makeServeDep({ model: undefined })) // POST /serve
808
- .mockResolvedValueOnce({ status: 'running' }) // pre-health dep check
809
- .mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/health' });
810
-
811
- const p = serveCommand(
812
- config,
813
- ['--runtime', 'llama.cpp',
814
- '--hf-repo', TINY_REPO,
815
- '--hf-file', TINY_FILE,
816
- '--max-cost', '1'],
817
- chalk,
818
- );
819
- await vi.advanceTimersByTimeAsync(5000);
820
- await p;
821
-
822
- // Correct image and env vars in the serve request
823
- const body = api.callApi.mock.calls[0][1].body;
824
- expect(body.image).toBe('michaelmanleyx/llama-cpp:server-cuda');
825
- expect(body.env.LLAMA_ARG_HF_REPO).toBe(TINY_REPO);
826
- expect(body.env.LLAMA_ARG_HF_FILE).toBe(TINY_FILE);
827
- expect(body.model).toBeUndefined();
828
-
829
- // Receipt finalised as ready with endpoint URL
830
- expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
831
- status: 'ready',
832
- endpointUrl: ENDPOINT_URL,
833
- }));
834
-
835
- expect(process.exitCode).toBeFalsy();
836
- });
837
- });
838
-
839
- // ─────────────────────────────────────────────────────────────────────────────
840
- // 13. --task routing: transcribe and image use /health; embed uses /models
841
- // ─────────────────────────────────────────────────────────────────────────────
842
-
843
- describe('--task routing for managed runtimes', () => {
844
- it('--task transcribe becomes ready via backend readiness (health_path=/health)', async () => {
845
- api.callApi
846
- .mockResolvedValueOnce(makeServeDep({ model: 'large-v3' })) // POST /serve
847
- .mockResolvedValueOnce({ status: 'running' }) // pre-health dep check
848
- .mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/health' });
849
-
850
- const p = serveCommand(
851
- config,
852
- ['large-v3', '--task', 'transcribe', '--max-cost', '5'],
853
- chalk,
854
- );
855
- await vi.advanceTimersByTimeAsync(5000);
856
- await p;
857
-
858
- expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({ status: 'ready' }));
859
- expect(process.exitCode).toBeFalsy();
860
- });
861
-
862
- it('--task transcribe sends task field to backend', async () => {
863
- api.callApi.mockResolvedValueOnce(makeServeDep({ model: 'large-v3' }));
864
-
865
- const p = serveCommand(
866
- config,
867
- ['large-v3', '--task', 'transcribe', '--max-cost', '5', '--no-wait'],
868
- chalk,
869
- );
870
- await vi.advanceTimersByTimeAsync(100);
871
- await p;
872
-
873
- const body = api.callApi.mock.calls[0][1].body;
874
- expect(body.task).toBe('transcribe');
875
- expect(body.model).toBe('large-v3');
876
- expect(process.exitCode).toBeFalsy();
877
- });
878
-
879
- it('--task image becomes ready via backend readiness (health_path=/health)', async () => {
880
- api.callApi
881
- .mockResolvedValueOnce(makeServeDep({ model: 'black-forest-labs/FLUX.1-schnell' }))
882
- .mockResolvedValueOnce({ status: 'running' })
883
- .mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/health' });
884
-
885
- const p = serveCommand(
886
- config,
887
- ['black-forest-labs/FLUX.1-schnell', '--task', 'image', '--max-cost', '10'],
888
- chalk,
889
- );
890
- await vi.advanceTimersByTimeAsync(5000);
891
- await p;
892
-
893
- expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({ status: 'ready' }));
894
- expect(process.exitCode).toBeFalsy();
895
- });
896
-
897
- it('--task image sends task field to backend', async () => {
898
- api.callApi.mockResolvedValueOnce(makeServeDep({ model: 'black-forest-labs/FLUX.1-schnell' }));
899
-
900
- const p = serveCommand(
901
- config,
902
- ['black-forest-labs/FLUX.1-schnell', '--task', 'image', '--max-cost', '10', '--no-wait'],
903
- chalk,
904
- );
905
- await vi.advanceTimersByTimeAsync(100);
906
- await p;
907
-
908
- const body = api.callApi.mock.calls[0][1].body;
909
- expect(body.task).toBe('image');
910
- expect(body.model).toBe('black-forest-labs/FLUX.1-schnell');
911
- expect(process.exitCode).toBeFalsy();
912
- });
913
-
914
- it('--task embed becomes ready via backend readiness (health_path=/v1/models)', async () => {
915
- api.callApi
916
- .mockResolvedValueOnce(makeServeDep({ model: 'BAAI/bge-large-en-v1.5' }))
917
- .mockResolvedValueOnce({ status: 'running' })
918
- .mockResolvedValueOnce(readyStatus());
919
-
920
- const p = serveCommand(
921
- config,
922
- ['BAAI/bge-large-en-v1.5', '--task', 'embed', '--max-cost', '5'],
923
- chalk,
924
- );
925
- await vi.advanceTimersByTimeAsync(5000);
926
- await p;
927
-
928
- expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({ status: 'ready' }));
929
- expect(process.exitCode).toBeFalsy();
930
- });
931
- });