badgr-cli 1.0.42 → 1.0.44
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 +81 -2
- package/package.json +2 -1
- package/src/badgr.js +3 -0
- package/src/catalog.js +15 -0
- package/src/commands/comfyui.js +35 -8
- package/src/commands/run.js +87 -99
- package/src/commands/serve.js +186 -123
- package/src/commands/train.js +85 -11
- package/src/progress.js +42 -0
- package/tests/productized-dry-run.test.js +141 -0
- package/tests/serve-lifecycle.test.js +196 -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,114 @@ 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 tested model routes 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('Tested model routes:');
|
|
519
|
+
expect(logged).toContain('qwen-7b');
|
|
520
|
+
expect(logged).toContain('You can also try a Hugging Face model ID:');
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
525
|
+
// Model support-level messaging — blessed route vs best-effort HF model vs
|
|
526
|
+
// custom container, plus gated-model HF_TOKEN guidance shown only on failure.
|
|
527
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
describe('model support-level messaging', () => {
|
|
530
|
+
it('labels a full Hugging Face model ID as best-effort, not tested', async () => {
|
|
531
|
+
api.callApi
|
|
532
|
+
.mockResolvedValueOnce(makeServeDep({ model: 'Qwen/Qwen2.5-7B-Instruct' }))
|
|
533
|
+
.mockResolvedValueOnce({ status: 'running' })
|
|
534
|
+
.mockResolvedValueOnce(readyStatus());
|
|
535
|
+
|
|
536
|
+
const p = serveCommand(config, ['Qwen/Qwen2.5-7B-Instruct', '--max-cost', '10'], chalk);
|
|
537
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
538
|
+
await p;
|
|
539
|
+
|
|
540
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
541
|
+
expect(logged).toContain('Route: best-effort Hugging Face model');
|
|
542
|
+
expect(logged).not.toContain('Route: tested');
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('labels a blessed alias as a tested route without extra caveats', async () => {
|
|
546
|
+
api.callApi
|
|
547
|
+
.mockResolvedValueOnce(makeServeDep({ model: 'Qwen/Qwen2.5-7B-Instruct' }))
|
|
548
|
+
.mockResolvedValueOnce({ status: 'running' })
|
|
549
|
+
.mockResolvedValueOnce(readyStatus());
|
|
550
|
+
|
|
551
|
+
const p = serveCommand(config, ['qwen-7b', '--max-cost', '10'], chalk);
|
|
552
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
553
|
+
await p;
|
|
554
|
+
|
|
555
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
556
|
+
expect(logged).toContain('Route: tested');
|
|
557
|
+
expect(logged).not.toContain('best-effort');
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('does not show HF_TOKEN guidance up front for a gated model that launches fine', async () => {
|
|
561
|
+
api.callApi
|
|
562
|
+
.mockResolvedValueOnce(makeServeDep({ model: 'meta-llama/Llama-3.1-8B-Instruct' }))
|
|
563
|
+
.mockResolvedValueOnce({ status: 'running' })
|
|
564
|
+
.mockResolvedValueOnce(readyStatus());
|
|
565
|
+
|
|
566
|
+
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '10'], chalk);
|
|
567
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
568
|
+
await p;
|
|
569
|
+
|
|
570
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
571
|
+
expect(logged).not.toContain('may require Hugging Face access');
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('shows HF_TOKEN guidance only once a gated model actually fails to start', async () => {
|
|
575
|
+
api.callApi
|
|
576
|
+
.mockResolvedValueOnce(makeServeDep({ model: 'meta-llama/Llama-3.1-8B-Instruct' }))
|
|
577
|
+
.mockResolvedValueOnce({ status: 'failed', error: 'gated repo — 401' });
|
|
578
|
+
|
|
579
|
+
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '10'], chalk);
|
|
580
|
+
await p;
|
|
581
|
+
|
|
582
|
+
const logged = console.error.mock.calls.flat().join('\n');
|
|
583
|
+
expect(logged).toContain('may require Hugging Face access');
|
|
584
|
+
expect(logged).toContain('--env HF_TOKEN=$HF_TOKEN');
|
|
585
|
+
expect(process.exitCode).toBe(1);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('omits HF_TOKEN guidance on failure when HF_TOKEN is already provided', async () => {
|
|
589
|
+
api.callApi
|
|
590
|
+
.mockResolvedValueOnce(makeServeDep({ model: 'meta-llama/Llama-3.1-8B-Instruct' }))
|
|
591
|
+
.mockResolvedValueOnce({ status: 'failed', error: 'crashed' });
|
|
592
|
+
|
|
593
|
+
const p = serveCommand(config, [
|
|
594
|
+
'meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '10', '--env', 'HF_TOKEN=hf_abc123',
|
|
595
|
+
], chalk);
|
|
596
|
+
await p;
|
|
597
|
+
|
|
598
|
+
const logged = console.error.mock.calls.flat().join('\n');
|
|
599
|
+
expect(logged).not.toContain('may require Hugging Face access');
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('labels a custom container as "custom server", not a Hugging Face model', async () => {
|
|
603
|
+
api.callApi.mockResolvedValueOnce(makeServeDep({ model: undefined, image: 'my/custom-server:latest' }));
|
|
604
|
+
|
|
605
|
+
const p = serveCommand(config, [
|
|
606
|
+
'--image', 'my/custom-server:latest', '--max-cost', '10', '--no-wait',
|
|
607
|
+
], chalk);
|
|
608
|
+
await p;
|
|
609
|
+
|
|
610
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
611
|
+
expect(logged).toContain('custom server');
|
|
612
|
+
expect(logged).toContain('Your container owns the app behavior.');
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
|
|
502
616
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
503
617
|
// 11. llama.cpp runtime (--runtime llama.cpp --gguf)
|
|
504
618
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -522,7 +636,6 @@ describe('--runtime llama.cpp', () => {
|
|
|
522
636
|
it('uses llama.cpp image with LLAMA_ARG_HF_REPO and LLAMA_ARG_HF_FILE env vars', async () => {
|
|
523
637
|
// --no-wait: only POST /serve called (1 mock needed)
|
|
524
638
|
api.callApi.mockResolvedValueOnce(makeServeDep());
|
|
525
|
-
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
526
639
|
|
|
527
640
|
const p = serveCommand(
|
|
528
641
|
config,
|
|
@@ -540,36 +653,11 @@ describe('--runtime llama.cpp', () => {
|
|
|
540
653
|
expect(process.exitCode).toBeFalsy();
|
|
541
654
|
});
|
|
542
655
|
|
|
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
656
|
it('records deployment and receipt as ready on success', async () => {
|
|
568
657
|
api.callApi
|
|
569
658
|
.mockResolvedValueOnce(makeServeDep())
|
|
570
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
571
|
-
|
|
572
|
-
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
659
|
+
.mockResolvedValueOnce({ status: 'running' })
|
|
660
|
+
.mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/health' });
|
|
573
661
|
|
|
574
662
|
const p = serveCommand(
|
|
575
663
|
config,
|
|
@@ -591,7 +679,6 @@ describe('--runtime llama.cpp', () => {
|
|
|
591
679
|
it('merges user --env with HF env vars', async () => {
|
|
592
680
|
// --no-wait: only POST /serve called (1 mock needed)
|
|
593
681
|
api.callApi.mockResolvedValueOnce(makeServeDep());
|
|
594
|
-
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
595
682
|
|
|
596
683
|
const p = serveCommand(
|
|
597
684
|
config,
|
|
@@ -613,24 +700,20 @@ describe('--runtime llama.cpp', () => {
|
|
|
613
700
|
// 12. E2E smoke test — tiny HF GGUF (ggml-org/tiny-llamas / stories260K.gguf)
|
|
614
701
|
//
|
|
615
702
|
// 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
|
|
703
|
+
// request lifecycle: arg parsing → correct serve body → readiness polling via
|
|
704
|
+
// Badgr's own deployment status → receipt recorded as ready → OpenAI-compatible
|
|
705
|
+
// base URL returned.
|
|
618
706
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
619
707
|
|
|
620
708
|
describe('llama.cpp E2E smoke — tiny GGUF (ggml-org/tiny-llamas)', () => {
|
|
621
709
|
const TINY_REPO = 'ggml-org/tiny-llamas';
|
|
622
710
|
const TINY_FILE = 'stories260K.gguf';
|
|
623
711
|
|
|
624
|
-
it('full lifecycle: arg parse → serve body →
|
|
712
|
+
it('full lifecycle: arg parse → serve body → readiness poll → ready receipt', async () => {
|
|
625
713
|
api.callApi
|
|
626
714
|
.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
|
-
});
|
|
715
|
+
.mockResolvedValueOnce({ status: 'running' }) // pre-health dep check
|
|
716
|
+
.mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/health' });
|
|
634
717
|
|
|
635
718
|
const p = serveCommand(
|
|
636
719
|
config,
|
|
@@ -650,10 +733,6 @@ describe('llama.cpp E2E smoke — tiny GGUF (ggml-org/tiny-llamas)', () => {
|
|
|
650
733
|
expect(body.env.LLAMA_ARG_HF_FILE).toBe(TINY_FILE);
|
|
651
734
|
expect(body.model).toBeUndefined();
|
|
652
735
|
|
|
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
736
|
// Receipt finalised as ready with endpoint URL
|
|
658
737
|
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
659
738
|
status: 'ready',
|
|
@@ -669,16 +748,11 @@ describe('llama.cpp E2E smoke — tiny GGUF (ggml-org/tiny-llamas)', () => {
|
|
|
669
748
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
670
749
|
|
|
671
750
|
describe('--task routing for managed runtimes', () => {
|
|
672
|
-
it('--task transcribe
|
|
751
|
+
it('--task transcribe becomes ready via backend readiness (health_path=/health)', async () => {
|
|
673
752
|
api.callApi
|
|
674
753
|
.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
|
-
});
|
|
754
|
+
.mockResolvedValueOnce({ status: 'running' }) // pre-health dep check
|
|
755
|
+
.mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/health' });
|
|
682
756
|
|
|
683
757
|
const p = serveCommand(
|
|
684
758
|
config,
|
|
@@ -688,14 +762,12 @@ describe('--task routing for managed runtimes', () => {
|
|
|
688
762
|
await vi.advanceTimersByTimeAsync(5000);
|
|
689
763
|
await p;
|
|
690
764
|
|
|
691
|
-
expect(
|
|
692
|
-
expect(fetchedUrls.some(u => u.endsWith('/models'))).toBe(false);
|
|
765
|
+
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({ status: 'ready' }));
|
|
693
766
|
expect(process.exitCode).toBeFalsy();
|
|
694
767
|
});
|
|
695
768
|
|
|
696
769
|
it('--task transcribe sends task field to backend', async () => {
|
|
697
770
|
api.callApi.mockResolvedValueOnce(makeServeDep({ model: 'large-v3' }));
|
|
698
|
-
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
699
771
|
|
|
700
772
|
const p = serveCommand(
|
|
701
773
|
config,
|
|
@@ -711,16 +783,11 @@ describe('--task routing for managed runtimes', () => {
|
|
|
711
783
|
expect(process.exitCode).toBeFalsy();
|
|
712
784
|
});
|
|
713
785
|
|
|
714
|
-
it('--task image
|
|
786
|
+
it('--task image becomes ready via backend readiness (health_path=/health)', async () => {
|
|
715
787
|
api.callApi
|
|
716
788
|
.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
|
-
});
|
|
789
|
+
.mockResolvedValueOnce({ status: 'running' })
|
|
790
|
+
.mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/health' });
|
|
724
791
|
|
|
725
792
|
const p = serveCommand(
|
|
726
793
|
config,
|
|
@@ -730,14 +797,12 @@ describe('--task routing for managed runtimes', () => {
|
|
|
730
797
|
await vi.advanceTimersByTimeAsync(5000);
|
|
731
798
|
await p;
|
|
732
799
|
|
|
733
|
-
expect(
|
|
734
|
-
expect(fetchedUrls.some(u => u.endsWith('/models'))).toBe(false);
|
|
800
|
+
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({ status: 'ready' }));
|
|
735
801
|
expect(process.exitCode).toBeFalsy();
|
|
736
802
|
});
|
|
737
803
|
|
|
738
804
|
it('--task image sends task field to backend', async () => {
|
|
739
805
|
api.callApi.mockResolvedValueOnce(makeServeDep({ model: 'black-forest-labs/FLUX.1-schnell' }));
|
|
740
|
-
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
741
806
|
|
|
742
807
|
const p = serveCommand(
|
|
743
808
|
config,
|
|
@@ -753,16 +818,11 @@ describe('--task routing for managed runtimes', () => {
|
|
|
753
818
|
expect(process.exitCode).toBeFalsy();
|
|
754
819
|
});
|
|
755
820
|
|
|
756
|
-
it('--task embed
|
|
821
|
+
it('--task embed becomes ready via backend readiness (health_path=/v1/models)', async () => {
|
|
757
822
|
api.callApi
|
|
758
823
|
.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
|
-
});
|
|
824
|
+
.mockResolvedValueOnce({ status: 'running' })
|
|
825
|
+
.mockResolvedValueOnce(readyStatus());
|
|
766
826
|
|
|
767
827
|
const p = serveCommand(
|
|
768
828
|
config,
|
|
@@ -772,8 +832,7 @@ describe('--task routing for managed runtimes', () => {
|
|
|
772
832
|
await vi.advanceTimersByTimeAsync(5000);
|
|
773
833
|
await p;
|
|
774
834
|
|
|
775
|
-
expect(
|
|
776
|
-
expect(fetchedUrls.some(u => u.includes('/health'))).toBe(false);
|
|
835
|
+
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({ status: 'ready' }));
|
|
777
836
|
expect(process.exitCode).toBeFalsy();
|
|
778
837
|
});
|
|
779
838
|
});
|
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
|
|