badgr-cli 1.0.41 → 1.0.43
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 +69 -2
- package/package.json +2 -1
- package/src/badgr.js +3 -0
- package/src/catalog.js +38 -0
- package/src/commands/comfyui.js +159 -0
- package/src/commands/run.js +14 -33
- package/src/commands/serve.js +62 -41
- package/src/commands/train.js +204 -1
- package/tests/productized-dry-run.test.js +141 -0
- package/tests/productized-runners.test.js +230 -0
- package/tests/serve-lifecycle.test.js +103 -137
- package/tests/template.test.js +11 -13
- package/tests/workload-templates.test.js +37 -2
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
* - Custom image skips health check automatically
|
|
14
14
|
* - Tier-1 unavailable → auto-expand to tier-2
|
|
15
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.
|
|
16
21
|
*/
|
|
17
22
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
18
23
|
import { serveCommand } from '../src/commands/serve.js';
|
|
@@ -72,6 +77,17 @@ function makeServeDep(overrides = {}) {
|
|
|
72
77
|
};
|
|
73
78
|
}
|
|
74
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
|
+
|
|
75
91
|
// ── Setup / teardown ──────────────────────────────────────────────────────────
|
|
76
92
|
|
|
77
93
|
beforeEach(() => {
|
|
@@ -83,6 +99,9 @@ beforeEach(() => {
|
|
|
83
99
|
vi.clearAllMocks();
|
|
84
100
|
store.generateReceiptId.mockReturnValue('rcpt-serve-001');
|
|
85
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'));
|
|
86
105
|
});
|
|
87
106
|
|
|
88
107
|
afterEach(() => {
|
|
@@ -99,14 +118,8 @@ describe('successful serve', () => {
|
|
|
99
118
|
it('Llama-3.1-8B on L40S via RunPod — endpoint ready, receipt records state', async () => {
|
|
100
119
|
api.callApi
|
|
101
120
|
.mockResolvedValueOnce(makeServeDep()) // POST /serve
|
|
102
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
103
|
-
|
|
104
|
-
// Mock the direct fetch call in waitForEndpoint
|
|
105
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
106
|
-
ok: true, status: 200,
|
|
107
|
-
json: async () => ({ data: [{ id: 'meta-llama/Llama-3.1-8B-Instruct' }] }),
|
|
108
|
-
text: async () => '',
|
|
109
|
-
});
|
|
121
|
+
.mockResolvedValueOnce({ status: 'running' }) // pre-health status check
|
|
122
|
+
.mockResolvedValueOnce(readyStatus()); // waitForEndpoint poll
|
|
110
123
|
|
|
111
124
|
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--gpu', 'L40S', '--max-cost', '5'], chalk);
|
|
112
125
|
await vi.advanceTimersByTimeAsync(5000);
|
|
@@ -121,6 +134,7 @@ describe('successful serve', () => {
|
|
|
121
134
|
status: 'ready',
|
|
122
135
|
endpointUrl: ENDPOINT_URL,
|
|
123
136
|
}));
|
|
137
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
124
138
|
expect(process.exitCode).toBeFalsy();
|
|
125
139
|
});
|
|
126
140
|
|
|
@@ -128,9 +142,8 @@ describe('successful serve', () => {
|
|
|
128
142
|
const dep = makeServeDep({ gpu_type: 'A100', provider: 'vastai', model: 'mistralai/Mistral-7B-v0.1' });
|
|
129
143
|
api.callApi
|
|
130
144
|
.mockResolvedValueOnce(dep)
|
|
131
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
132
|
-
|
|
133
|
-
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
|
145
|
+
.mockResolvedValueOnce({ status: 'running' })
|
|
146
|
+
.mockResolvedValueOnce(readyStatus());
|
|
134
147
|
|
|
135
148
|
const p = serveCommand(config, ['mistralai/Mistral-7B-v0.1', '--gpu', 'A100', '--max-cost', '5'], chalk);
|
|
136
149
|
await vi.advanceTimersByTimeAsync(5000);
|
|
@@ -150,12 +163,10 @@ describe('successful serve', () => {
|
|
|
150
163
|
|
|
151
164
|
describe('billing ends when deployment stops', () => {
|
|
152
165
|
it('serveCommand calls terminateDeployment on SIGINT (graceful stop)', async () => {
|
|
153
|
-
// Simulate a deployment that is stopped externally (failed status returned during health check)
|
|
154
166
|
api.callApi
|
|
155
167
|
.mockResolvedValueOnce(makeServeDep())
|
|
156
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
157
|
-
|
|
158
|
-
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
|
168
|
+
.mockResolvedValueOnce({ status: 'running' })
|
|
169
|
+
.mockResolvedValueOnce(readyStatus());
|
|
159
170
|
|
|
160
171
|
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
|
|
161
172
|
await vi.advanceTimersByTimeAsync(5000);
|
|
@@ -199,9 +210,6 @@ describe('deployment fails during startup', () => {
|
|
|
199
210
|
.mockResolvedValueOnce({ status: 'running' }) // pre-health check ok
|
|
200
211
|
.mockResolvedValueOnce({ status: 'failed', error: 'CUDA error' }); // poll inside waitForEndpoint
|
|
201
212
|
|
|
202
|
-
// Health check endpoint doesn't respond (dep crashed)
|
|
203
|
-
global.fetch = vi.fn().mockRejectedValue(new Error('Connection refused'));
|
|
204
|
-
|
|
205
213
|
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
|
|
206
214
|
// need to advance past the 8000ms sleep in waitForEndpoint loop
|
|
207
215
|
await vi.advanceTimersByTimeAsync(10000);
|
|
@@ -212,6 +220,25 @@ describe('deployment fails during startup', () => {
|
|
|
212
220
|
}));
|
|
213
221
|
expect(process.exitCode).toBe(1);
|
|
214
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
|
+
});
|
|
215
242
|
});
|
|
216
243
|
|
|
217
244
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -219,18 +246,15 @@ describe('deployment fails during startup', () => {
|
|
|
219
246
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
220
247
|
|
|
221
248
|
describe('health check timeout', () => {
|
|
222
|
-
it('records health_check_timeout if
|
|
249
|
+
it('records health_check_timeout if endpoint_ready never becomes true within 15 minutes', async () => {
|
|
223
250
|
api.callApi
|
|
224
251
|
.mockResolvedValueOnce(makeServeDep()) // POST /serve
|
|
225
252
|
.mockResolvedValueOnce({ status: 'running' }) // pre-health check
|
|
226
|
-
.mockResolvedValue(
|
|
227
|
-
|
|
228
|
-
// Health endpoint never responds
|
|
229
|
-
global.fetch = vi.fn().mockRejectedValue(new Error('ETIMEDOUT'));
|
|
253
|
+
.mockResolvedValue(notReadyStatus()); // all subsequent dep polls: still not ready
|
|
230
254
|
|
|
231
255
|
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
|
|
232
|
-
// waitForEndpoint timeout is
|
|
233
|
-
await vi.advanceTimersByTimeAsync(
|
|
256
|
+
// waitForEndpoint timeout is VLLM_SERVE_WAIT_MS = 15 * 60 * 1000
|
|
257
|
+
await vi.advanceTimersByTimeAsync(910000);
|
|
234
258
|
await p;
|
|
235
259
|
|
|
236
260
|
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
@@ -306,15 +330,15 @@ describe('invalid model ID or no capacity', () => {
|
|
|
306
330
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
307
331
|
|
|
308
332
|
describe('--no-wait flag', () => {
|
|
309
|
-
it('returns immediately without polling
|
|
333
|
+
it('returns immediately without polling deployment status for readiness', async () => {
|
|
310
334
|
api.callApi.mockResolvedValueOnce(makeServeDep());
|
|
311
|
-
global.fetch = vi.fn(); // should never be called
|
|
312
335
|
|
|
313
336
|
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--no-wait', '--max-cost', '5'], chalk);
|
|
314
337
|
await vi.advanceTimersByTimeAsync(100);
|
|
315
338
|
await p;
|
|
316
339
|
|
|
317
|
-
|
|
340
|
+
// Only the POST /serve call — no readiness polling at all.
|
|
341
|
+
expect(api.callApi).toHaveBeenCalledTimes(1);
|
|
318
342
|
// Receipt records 'starting' (not 'ready') when --no-wait is used
|
|
319
343
|
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
320
344
|
status: 'starting',
|
|
@@ -330,7 +354,6 @@ describe('--no-wait flag', () => {
|
|
|
330
354
|
describe('custom image health check', () => {
|
|
331
355
|
it('skips health check for non-comfyui --image (no known readiness path)', async () => {
|
|
332
356
|
api.callApi.mockResolvedValueOnce(makeServeDep({ model: null }));
|
|
333
|
-
global.fetch = vi.fn(); // should not be called
|
|
334
357
|
|
|
335
358
|
const p = serveCommand(
|
|
336
359
|
config,
|
|
@@ -340,20 +363,15 @@ describe('custom image health check', () => {
|
|
|
340
363
|
await vi.advanceTimersByTimeAsync(100);
|
|
341
364
|
await p;
|
|
342
365
|
|
|
343
|
-
expect(
|
|
366
|
+
expect(api.callApi).toHaveBeenCalledTimes(1);
|
|
344
367
|
expect(process.exitCode).toBeFalsy();
|
|
345
368
|
});
|
|
346
369
|
|
|
347
|
-
it('auto-detects comfyui image and
|
|
370
|
+
it('auto-detects comfyui image and waits on backend-reported endpoint_ready', async () => {
|
|
348
371
|
api.callApi
|
|
349
372
|
.mockResolvedValueOnce(makeServeDep({ model: null })) // POST /serve
|
|
350
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
351
|
-
|
|
352
|
-
const fetchedUrls = [];
|
|
353
|
-
global.fetch = vi.fn().mockImplementation((url) => {
|
|
354
|
-
fetchedUrls.push(url);
|
|
355
|
-
return Promise.resolve({ ok: true, status: 200 });
|
|
356
|
-
});
|
|
373
|
+
.mockResolvedValueOnce({ status: 'running' }) // pre-health dep status check
|
|
374
|
+
.mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/system_stats' });
|
|
357
375
|
|
|
358
376
|
const p = serveCommand(
|
|
359
377
|
config,
|
|
@@ -363,21 +381,15 @@ describe('custom image health check', () => {
|
|
|
363
381
|
await vi.advanceTimersByTimeAsync(5000);
|
|
364
382
|
await p;
|
|
365
383
|
|
|
366
|
-
expect(
|
|
367
|
-
expect(fetchedUrls.some(u => u.includes('/models'))).toBe(false);
|
|
384
|
+
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({ status: 'ready' }));
|
|
368
385
|
expect(process.exitCode).toBeFalsy();
|
|
369
386
|
});
|
|
370
387
|
|
|
371
|
-
it('
|
|
388
|
+
it('sends --health-path override to the backend when explicitly provided', async () => {
|
|
372
389
|
api.callApi
|
|
373
390
|
.mockResolvedValueOnce(makeServeDep({ model: null })) // POST /serve
|
|
374
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
375
|
-
|
|
376
|
-
const fetchedUrls = [];
|
|
377
|
-
global.fetch = vi.fn().mockImplementation((url) => {
|
|
378
|
-
fetchedUrls.push(url);
|
|
379
|
-
return Promise.resolve({ ok: true, status: 200 });
|
|
380
|
-
});
|
|
391
|
+
.mockResolvedValueOnce({ status: 'running' }) // pre-health dep status check
|
|
392
|
+
.mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/health' });
|
|
381
393
|
|
|
382
394
|
const p = serveCommand(
|
|
383
395
|
config,
|
|
@@ -387,21 +399,16 @@ describe('custom image health check', () => {
|
|
|
387
399
|
await vi.advanceTimersByTimeAsync(5000);
|
|
388
400
|
await p;
|
|
389
401
|
|
|
390
|
-
|
|
391
|
-
expect(
|
|
402
|
+
const body = api.callApi.mock.calls[0][1].body;
|
|
403
|
+
expect(body.health_path).toBe('/health');
|
|
392
404
|
expect(process.exitCode).toBeFalsy();
|
|
393
405
|
});
|
|
394
406
|
|
|
395
407
|
it('--health-path also works for model-based serve (overrides /models default)', async () => {
|
|
396
408
|
api.callApi
|
|
397
409
|
.mockResolvedValueOnce(makeServeDep()) // POST /serve
|
|
398
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
399
|
-
|
|
400
|
-
const fetchedUrls = [];
|
|
401
|
-
global.fetch = vi.fn().mockImplementation((url) => {
|
|
402
|
-
fetchedUrls.push(url);
|
|
403
|
-
return Promise.resolve({ ok: true, status: 200 });
|
|
404
|
-
});
|
|
410
|
+
.mockResolvedValueOnce({ status: 'running' }) // pre-health dep status check
|
|
411
|
+
.mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/v1/health' });
|
|
405
412
|
|
|
406
413
|
const p = serveCommand(
|
|
407
414
|
config,
|
|
@@ -411,8 +418,8 @@ describe('custom image health check', () => {
|
|
|
411
418
|
await vi.advanceTimersByTimeAsync(5000);
|
|
412
419
|
await p;
|
|
413
420
|
|
|
414
|
-
|
|
415
|
-
expect(
|
|
421
|
+
const body = api.callApi.mock.calls[0][1].body;
|
|
422
|
+
expect(body.health_path).toBe('/v1/health');
|
|
416
423
|
expect(process.exitCode).toBeFalsy();
|
|
417
424
|
});
|
|
418
425
|
});
|
|
@@ -430,9 +437,8 @@ describe('tier-2 fallback for serve', () => {
|
|
|
430
437
|
api.callApi
|
|
431
438
|
.mockRejectedValueOnce(capacityErr) // tier-1
|
|
432
439
|
.mockResolvedValueOnce(makeServeDep({ provider: 'vast', tier: '2' })) // tier-2
|
|
433
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
434
|
-
|
|
435
|
-
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
|
440
|
+
.mockResolvedValueOnce({ status: 'running' }) // pre-health
|
|
441
|
+
.mockResolvedValueOnce(readyStatus());
|
|
436
442
|
|
|
437
443
|
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
|
|
438
444
|
await vi.advanceTimersByTimeAsync(5000);
|
|
@@ -499,6 +505,21 @@ describe('input validation for serve', () => {
|
|
|
499
505
|
});
|
|
500
506
|
});
|
|
501
507
|
|
|
508
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
509
|
+
// --list-aliases — discoverability for blessed vLLM aliases
|
|
510
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
511
|
+
|
|
512
|
+
describe('--list-aliases', () => {
|
|
513
|
+
it('prints blessed aliases without requiring a model or API call', async () => {
|
|
514
|
+
await serveCommand({ apiKey: null, baseUrl: 'https://api.test/v1' }, ['--list-aliases'], chalk);
|
|
515
|
+
expect(api.callApi).not.toHaveBeenCalled();
|
|
516
|
+
expect(process.exitCode).toBeFalsy();
|
|
517
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
518
|
+
expect(logged).toContain('qwen-7b');
|
|
519
|
+
expect(logged).toContain('Qwen/Qwen2.5-7B-Instruct');
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
502
523
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
503
524
|
// 11. llama.cpp runtime (--runtime llama.cpp --gguf)
|
|
504
525
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -522,7 +543,6 @@ describe('--runtime llama.cpp', () => {
|
|
|
522
543
|
it('uses llama.cpp image with LLAMA_ARG_HF_REPO and LLAMA_ARG_HF_FILE env vars', async () => {
|
|
523
544
|
// --no-wait: only POST /serve called (1 mock needed)
|
|
524
545
|
api.callApi.mockResolvedValueOnce(makeServeDep());
|
|
525
|
-
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
526
546
|
|
|
527
547
|
const p = serveCommand(
|
|
528
548
|
config,
|
|
@@ -540,36 +560,11 @@ describe('--runtime llama.cpp', () => {
|
|
|
540
560
|
expect(process.exitCode).toBeFalsy();
|
|
541
561
|
});
|
|
542
562
|
|
|
543
|
-
it('health-checks /health (not /models)', async () => {
|
|
544
|
-
api.callApi
|
|
545
|
-
.mockResolvedValueOnce(makeServeDep())
|
|
546
|
-
.mockResolvedValueOnce({ status: 'running' });
|
|
547
|
-
|
|
548
|
-
const fetchedUrls = [];
|
|
549
|
-
global.fetch = vi.fn().mockImplementation((url) => {
|
|
550
|
-
fetchedUrls.push(url);
|
|
551
|
-
return Promise.resolve({ ok: true, status: 200 });
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
const p = serveCommand(
|
|
555
|
-
config,
|
|
556
|
-
['--runtime', 'llama.cpp', '--hf-repo', HF_REPO, '--hf-file', HF_FILE, '--max-cost', '10'],
|
|
557
|
-
chalk,
|
|
558
|
-
);
|
|
559
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
560
|
-
await p;
|
|
561
|
-
|
|
562
|
-
expect(fetchedUrls.some(u => u.includes('/health'))).toBe(true);
|
|
563
|
-
expect(fetchedUrls.some(u => u.endsWith('/models'))).toBe(false);
|
|
564
|
-
expect(process.exitCode).toBeFalsy();
|
|
565
|
-
});
|
|
566
|
-
|
|
567
563
|
it('records deployment and receipt as ready on success', async () => {
|
|
568
564
|
api.callApi
|
|
569
565
|
.mockResolvedValueOnce(makeServeDep())
|
|
570
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
571
|
-
|
|
572
|
-
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
566
|
+
.mockResolvedValueOnce({ status: 'running' })
|
|
567
|
+
.mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/health' });
|
|
573
568
|
|
|
574
569
|
const p = serveCommand(
|
|
575
570
|
config,
|
|
@@ -591,7 +586,6 @@ describe('--runtime llama.cpp', () => {
|
|
|
591
586
|
it('merges user --env with HF env vars', async () => {
|
|
592
587
|
// --no-wait: only POST /serve called (1 mock needed)
|
|
593
588
|
api.callApi.mockResolvedValueOnce(makeServeDep());
|
|
594
|
-
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
595
589
|
|
|
596
590
|
const p = serveCommand(
|
|
597
591
|
config,
|
|
@@ -613,24 +607,20 @@ describe('--runtime llama.cpp', () => {
|
|
|
613
607
|
// 12. E2E smoke test — tiny HF GGUF (ggml-org/tiny-llamas / stories260K.gguf)
|
|
614
608
|
//
|
|
615
609
|
// Uses a real, publicly-available tiny GGUF (~1 MB) to verify the full
|
|
616
|
-
// request lifecycle: arg parsing → correct serve body →
|
|
617
|
-
// receipt recorded as ready → OpenAI-compatible
|
|
610
|
+
// request lifecycle: arg parsing → correct serve body → readiness polling via
|
|
611
|
+
// Badgr's own deployment status → receipt recorded as ready → OpenAI-compatible
|
|
612
|
+
// base URL returned.
|
|
618
613
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
619
614
|
|
|
620
615
|
describe('llama.cpp E2E smoke — tiny GGUF (ggml-org/tiny-llamas)', () => {
|
|
621
616
|
const TINY_REPO = 'ggml-org/tiny-llamas';
|
|
622
617
|
const TINY_FILE = 'stories260K.gguf';
|
|
623
618
|
|
|
624
|
-
it('full lifecycle: arg parse → serve body →
|
|
619
|
+
it('full lifecycle: arg parse → serve body → readiness poll → ready receipt', async () => {
|
|
625
620
|
api.callApi
|
|
626
621
|
.mockResolvedValueOnce(makeServeDep({ model: undefined })) // POST /serve
|
|
627
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
628
|
-
|
|
629
|
-
const fetchedUrls = [];
|
|
630
|
-
global.fetch = vi.fn().mockImplementation((url) => {
|
|
631
|
-
fetchedUrls.push(url);
|
|
632
|
-
return Promise.resolve({ ok: true, status: 200 });
|
|
633
|
-
});
|
|
622
|
+
.mockResolvedValueOnce({ status: 'running' }) // pre-health dep check
|
|
623
|
+
.mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/health' });
|
|
634
624
|
|
|
635
625
|
const p = serveCommand(
|
|
636
626
|
config,
|
|
@@ -650,10 +640,6 @@ describe('llama.cpp E2E smoke — tiny GGUF (ggml-org/tiny-llamas)', () => {
|
|
|
650
640
|
expect(body.env.LLAMA_ARG_HF_FILE).toBe(TINY_FILE);
|
|
651
641
|
expect(body.model).toBeUndefined();
|
|
652
642
|
|
|
653
|
-
// Readiness check hits /health, not /models
|
|
654
|
-
expect(fetchedUrls.some(u => u.includes('/health'))).toBe(true);
|
|
655
|
-
expect(fetchedUrls.some(u => u.endsWith('/models'))).toBe(false);
|
|
656
|
-
|
|
657
643
|
// Receipt finalised as ready with endpoint URL
|
|
658
644
|
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
659
645
|
status: 'ready',
|
|
@@ -669,16 +655,11 @@ describe('llama.cpp E2E smoke — tiny GGUF (ggml-org/tiny-llamas)', () => {
|
|
|
669
655
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
670
656
|
|
|
671
657
|
describe('--task routing for managed runtimes', () => {
|
|
672
|
-
it('--task transcribe
|
|
658
|
+
it('--task transcribe becomes ready via backend readiness (health_path=/health)', async () => {
|
|
673
659
|
api.callApi
|
|
674
660
|
.mockResolvedValueOnce(makeServeDep({ model: 'large-v3' })) // POST /serve
|
|
675
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
676
|
-
|
|
677
|
-
const fetchedUrls = [];
|
|
678
|
-
global.fetch = vi.fn().mockImplementation((url) => {
|
|
679
|
-
fetchedUrls.push(url);
|
|
680
|
-
return Promise.resolve({ ok: true, status: 200 });
|
|
681
|
-
});
|
|
661
|
+
.mockResolvedValueOnce({ status: 'running' }) // pre-health dep check
|
|
662
|
+
.mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/health' });
|
|
682
663
|
|
|
683
664
|
const p = serveCommand(
|
|
684
665
|
config,
|
|
@@ -688,14 +669,12 @@ describe('--task routing for managed runtimes', () => {
|
|
|
688
669
|
await vi.advanceTimersByTimeAsync(5000);
|
|
689
670
|
await p;
|
|
690
671
|
|
|
691
|
-
expect(
|
|
692
|
-
expect(fetchedUrls.some(u => u.endsWith('/models'))).toBe(false);
|
|
672
|
+
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({ status: 'ready' }));
|
|
693
673
|
expect(process.exitCode).toBeFalsy();
|
|
694
674
|
});
|
|
695
675
|
|
|
696
676
|
it('--task transcribe sends task field to backend', async () => {
|
|
697
677
|
api.callApi.mockResolvedValueOnce(makeServeDep({ model: 'large-v3' }));
|
|
698
|
-
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
699
678
|
|
|
700
679
|
const p = serveCommand(
|
|
701
680
|
config,
|
|
@@ -711,16 +690,11 @@ describe('--task routing for managed runtimes', () => {
|
|
|
711
690
|
expect(process.exitCode).toBeFalsy();
|
|
712
691
|
});
|
|
713
692
|
|
|
714
|
-
it('--task image
|
|
693
|
+
it('--task image becomes ready via backend readiness (health_path=/health)', async () => {
|
|
715
694
|
api.callApi
|
|
716
695
|
.mockResolvedValueOnce(makeServeDep({ model: 'black-forest-labs/FLUX.1-schnell' }))
|
|
717
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
718
|
-
|
|
719
|
-
const fetchedUrls = [];
|
|
720
|
-
global.fetch = vi.fn().mockImplementation((url) => {
|
|
721
|
-
fetchedUrls.push(url);
|
|
722
|
-
return Promise.resolve({ ok: true, status: 200 });
|
|
723
|
-
});
|
|
696
|
+
.mockResolvedValueOnce({ status: 'running' })
|
|
697
|
+
.mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/health' });
|
|
724
698
|
|
|
725
699
|
const p = serveCommand(
|
|
726
700
|
config,
|
|
@@ -730,14 +704,12 @@ describe('--task routing for managed runtimes', () => {
|
|
|
730
704
|
await vi.advanceTimersByTimeAsync(5000);
|
|
731
705
|
await p;
|
|
732
706
|
|
|
733
|
-
expect(
|
|
734
|
-
expect(fetchedUrls.some(u => u.endsWith('/models'))).toBe(false);
|
|
707
|
+
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({ status: 'ready' }));
|
|
735
708
|
expect(process.exitCode).toBeFalsy();
|
|
736
709
|
});
|
|
737
710
|
|
|
738
711
|
it('--task image sends task field to backend', async () => {
|
|
739
712
|
api.callApi.mockResolvedValueOnce(makeServeDep({ model: 'black-forest-labs/FLUX.1-schnell' }));
|
|
740
|
-
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
741
713
|
|
|
742
714
|
const p = serveCommand(
|
|
743
715
|
config,
|
|
@@ -753,16 +725,11 @@ describe('--task routing for managed runtimes', () => {
|
|
|
753
725
|
expect(process.exitCode).toBeFalsy();
|
|
754
726
|
});
|
|
755
727
|
|
|
756
|
-
it('--task embed
|
|
728
|
+
it('--task embed becomes ready via backend readiness (health_path=/v1/models)', async () => {
|
|
757
729
|
api.callApi
|
|
758
730
|
.mockResolvedValueOnce(makeServeDep({ model: 'BAAI/bge-large-en-v1.5' }))
|
|
759
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
760
|
-
|
|
761
|
-
const fetchedUrls = [];
|
|
762
|
-
global.fetch = vi.fn().mockImplementation((url) => {
|
|
763
|
-
fetchedUrls.push(url);
|
|
764
|
-
return Promise.resolve({ ok: true, status: 200 });
|
|
765
|
-
});
|
|
731
|
+
.mockResolvedValueOnce({ status: 'running' })
|
|
732
|
+
.mockResolvedValueOnce(readyStatus());
|
|
766
733
|
|
|
767
734
|
const p = serveCommand(
|
|
768
735
|
config,
|
|
@@ -772,8 +739,7 @@ describe('--task routing for managed runtimes', () => {
|
|
|
772
739
|
await vi.advanceTimersByTimeAsync(5000);
|
|
773
740
|
await p;
|
|
774
741
|
|
|
775
|
-
expect(
|
|
776
|
-
expect(fetchedUrls.some(u => u.includes('/health'))).toBe(false);
|
|
742
|
+
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({ status: 'ready' }));
|
|
777
743
|
expect(process.exitCode).toBeFalsy();
|
|
778
744
|
});
|
|
779
745
|
});
|
package/tests/template.test.js
CHANGED
|
@@ -327,18 +327,21 @@ describe('parseTemplateOverrides', () => {
|
|
|
327
327
|
// Sequence for serveCommand:
|
|
328
328
|
// callApi('/serve', ...) → dep (via callWithFallback → callApi)
|
|
329
329
|
// callApi('/deployments/<id>', ...) → { status: 'running' } (pre-health check)
|
|
330
|
-
//
|
|
330
|
+
// callApi('/deployments/<id>', ...) → { status: 'running', endpoint_ready: true } (waitForEndpoint poll)
|
|
331
|
+
//
|
|
332
|
+
// Readiness comes entirely from Badgr's own deployment status — the CLI never
|
|
333
|
+
// fetches the pod/RunPod-proxy endpoint directly.
|
|
331
334
|
|
|
332
|
-
function setupServe(depOverrides = {}) {
|
|
335
|
+
function setupServe(depOverrides = {}, readyOverrides = {}) {
|
|
333
336
|
api.callApi
|
|
334
337
|
.mockResolvedValueOnce(makeServeDep(depOverrides)) // POST /serve
|
|
335
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
336
|
-
|
|
338
|
+
.mockResolvedValueOnce({ status: 'running' }) // pre-health dep status
|
|
339
|
+
.mockResolvedValueOnce({ status: 'running', endpoint_ready: true, ...readyOverrides }); // waitForEndpoint poll
|
|
337
340
|
}
|
|
338
341
|
|
|
339
342
|
describe('badgr serve template <name>', () => {
|
|
340
|
-
it('vllm: dispatches with correct image, max_cost_usd, and
|
|
341
|
-
setupServe();
|
|
343
|
+
it('vllm: dispatches with correct image, max_cost_usd, and readiness on /v1/models', async () => {
|
|
344
|
+
setupServe({}, { health_path: '/v1/models' });
|
|
342
345
|
const p = serveCommand(config, ['template', 'vllm', '--max-cost', '5'], chalk);
|
|
343
346
|
await vi.advanceTimersByTimeAsync(5000);
|
|
344
347
|
await p;
|
|
@@ -347,22 +350,17 @@ describe('badgr serve template <name>', () => {
|
|
|
347
350
|
expect(route).toBe('/serve');
|
|
348
351
|
expect(opts.body.image).toBe('vllm/vllm-openai:latest');
|
|
349
352
|
expect(opts.body.max_cost_usd).toBe(5);
|
|
350
|
-
// health check fetch should use the /v1/models path from the template
|
|
351
|
-
const fetchUrl = global.fetch.mock.calls[0]?.[0] ?? '';
|
|
352
|
-
expect(fetchUrl).toContain('/v1/models');
|
|
353
353
|
expect(process.exitCode).toBeFalsy();
|
|
354
354
|
});
|
|
355
355
|
|
|
356
|
-
it('comfyui: dispatches with ComfyUI image and
|
|
357
|
-
setupServe();
|
|
356
|
+
it('comfyui: dispatches with ComfyUI image and readiness on /system_stats', async () => {
|
|
357
|
+
setupServe({}, { health_path: '/system_stats' });
|
|
358
358
|
const p = serveCommand(config, ['template', 'comfyui', '--max-cost', '3'], chalk);
|
|
359
359
|
await vi.advanceTimersByTimeAsync(5000);
|
|
360
360
|
await p;
|
|
361
361
|
|
|
362
362
|
const [, opts] = api.callApi.mock.calls[0];
|
|
363
363
|
expect(opts.body.image).toBe('yanwk/comfyui-boot:cu126-megapak');
|
|
364
|
-
const fetchUrl = global.fetch.mock.calls[0]?.[0] ?? '';
|
|
365
|
-
expect(fetchUrl).toContain('/system_stats');
|
|
366
364
|
expect(process.exitCode).toBeFalsy();
|
|
367
365
|
});
|
|
368
366
|
|
|
@@ -485,9 +485,44 @@ describe('trainCommand', () => {
|
|
|
485
485
|
expect(body.max_runtime_seconds).toBe(60 * 60);
|
|
486
486
|
});
|
|
487
487
|
|
|
488
|
-
it('
|
|
488
|
+
it('blocks unsloth config instead of running a mismatched command', async () => {
|
|
489
489
|
fs.existsSync.mockReturnValue(true);
|
|
490
490
|
fs.readFileSync.mockReturnValue('# unsloth training config\nmodel: llama\n');
|
|
491
|
+
|
|
492
|
+
await trainCommand(config, ['config.yaml', '--detach'], chalk);
|
|
493
|
+
|
|
494
|
+
expect(process.exitCode).toBe(1);
|
|
495
|
+
expect(fallback.callWithFallback).not.toHaveBeenCalled();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('blocks generic (unrecognized) config instead of guessing a command', async () => {
|
|
499
|
+
fs.existsSync.mockReturnValue(true);
|
|
500
|
+
fs.readFileSync.mockReturnValue('some_key: some_value\nother: 123\n');
|
|
501
|
+
|
|
502
|
+
await trainCommand(config, ['config.yaml', '--detach'], chalk);
|
|
503
|
+
|
|
504
|
+
expect(process.exitCode).toBe(1);
|
|
505
|
+
expect(fallback.callWithFallback).not.toHaveBeenCalled();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('uses the trl CLI command for a trl-detected config', async () => {
|
|
509
|
+
fs.existsSync.mockReturnValue(true);
|
|
510
|
+
fs.readFileSync.mockReturnValue('trainer: SFTTrainer\nmodel: mistral\n');
|
|
511
|
+
fallback.callWithFallback.mockResolvedValue(makeRunDep());
|
|
512
|
+
|
|
513
|
+
const p = trainCommand(config, ['config.yaml', '--detach'], chalk);
|
|
514
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
515
|
+
await p;
|
|
516
|
+
|
|
517
|
+
const bodyBuilder = fallback.callWithFallback.mock.calls[0][2];
|
|
518
|
+
const body = bodyBuilder();
|
|
519
|
+
expect(body.image).toBe('huggingface/trl-source:latest');
|
|
520
|
+
expect(body.command[2]).toContain('trl sft --config /tmp/config.yaml');
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('uses the axolotl CLI command for an axolotl-detected config', async () => {
|
|
524
|
+
fs.existsSync.mockReturnValue(true);
|
|
525
|
+
fs.readFileSync.mockReturnValue('base_model: meta-llama/Llama-2-7b-hf\nsequence_len: 2048\n');
|
|
491
526
|
fallback.callWithFallback.mockResolvedValue(makeRunDep());
|
|
492
527
|
|
|
493
528
|
const p = trainCommand(config, ['config.yaml', '--detach'], chalk);
|
|
@@ -496,7 +531,7 @@ describe('trainCommand', () => {
|
|
|
496
531
|
|
|
497
532
|
const bodyBuilder = fallback.callWithFallback.mock.calls[0][2];
|
|
498
533
|
const body = bodyBuilder();
|
|
499
|
-
expect(body.
|
|
534
|
+
expect(body.command[2]).toContain('axolotl train /tmp/config.yaml');
|
|
500
535
|
});
|
|
501
536
|
});
|
|
502
537
|
|