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.
@@ -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' }); // pre-health status check
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 /models never responds within 5 minutes', async () => {
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({ status: 'running' }); // all subsequent dep polls: still running
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 5 * 60 * 1000 = 300000ms
233
- await vi.advanceTimersByTimeAsync(310000);
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 /models', async () => {
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
- expect(global.fetch).not.toHaveBeenCalled();
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(global.fetch).not.toHaveBeenCalled();
366
+ expect(api.callApi).toHaveBeenCalledTimes(1);
344
367
  expect(process.exitCode).toBeFalsy();
345
368
  });
346
369
 
347
- it('auto-detects comfyui image and polls /system_stats instead of skipping', async () => {
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' }); // pre-health dep status check
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(fetchedUrls.some(u => u.includes('/system_stats'))).toBe(true);
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('uses --health-path when explicitly provided, overriding defaults', async () => {
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' }); // pre-health dep status check
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
- expect(fetchedUrls.some(u => u.includes('/health'))).toBe(true);
391
- expect(fetchedUrls.some(u => u.includes('/models'))).toBe(false);
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' }); // pre-health dep status check
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
- expect(fetchedUrls.some(u => u.includes('/v1/health'))).toBe(true);
415
- expect(fetchedUrls.some(u => u.endsWith('/models'))).toBe(false);
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' }); // pre-health
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 → /health polling
617
- // receipt recorded as ready → OpenAI-compatible base URL returned.
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 → /health → ready receipt', async () => {
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' }); // pre-health dep check
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 polls /health instead of /models', async () => {
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' }); // pre-health dep check
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(fetchedUrls.some(u => u.includes('/health'))).toBe(true);
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 polls /health instead of /models', async () => {
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(fetchedUrls.some(u => u.includes('/health'))).toBe(true);
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 still polls /models (vLLM path)', async () => {
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(fetchedUrls.some(u => u.endsWith('/models'))).toBe(true);
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
  });
@@ -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
- // global.fetch(endpoint + health_path) → { ok: true }
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' }); // pre-health dep status
336
- global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
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 health check on /v1/models', async () => {
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 health check on /system_stats', async () => {
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('uses unsloth image for unsloth config', async () => {
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.image).toBe('unslothai/unsloth:latest');
534
+ expect(body.command[2]).toContain('axolotl train /tmp/config.yaml');
500
535
  });
501
536
  });
502
537