badgr-cli 1.0.21 → 1.0.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "badgr-cli",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
4
4
  "description": "Badgr, run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
package/src/badgr.js CHANGED
@@ -11,6 +11,7 @@ import { runCommand } from './commands/run.js';
11
11
  import { serveCommand } from './commands/serve.js';
12
12
  import { modelsCommand } from './commands/models.js';
13
13
  import { capacityCommand } from './commands/capacity.js';
14
+ import { testCommand } from './commands/test-run.js';
14
15
 
15
16
  const HELP = `
16
17
  ${chalk.bold('badgr')} — run or serve GPU workloads from one command
@@ -23,9 +24,13 @@ ${chalk.bold('COMMANDS')}
23
24
  ${chalk.cyan('badgr logs <id>')} Stream logs for a running job or endpoint
24
25
  ${chalk.cyan('badgr down <id>')} Stop a deployment and end billing
25
26
  ${chalk.cyan('badgr receipts')} Show cost history
27
+ ${chalk.cyan('badgr test')} Run an end-to-end test (provision → run → teardown)
26
28
  ${chalk.cyan('badgr capacity')} Check what GPU capacity is available right now
27
29
 
28
30
  ${chalk.bold('EXAMPLES')}
31
+ ${chalk.dim('# Verify the stack works end-to-end:')}
32
+ badgr test
33
+
29
34
  ${chalk.dim('# Simplest — Badgr picks the GPU (RunPod, reliable):')}
30
35
  badgr run python train.py
31
36
  badgr serve meta-llama/Llama-3.1-8B-Instruct
@@ -98,6 +103,7 @@ async function main() {
98
103
  case 'receipts': return receiptsCommand(config, rest, chalk);
99
104
  case 'models': return modelsCommand(config, chalk);
100
105
  case 'capacity': return capacityCommand(config, rest, chalk);
106
+ case 'test': return testCommand(config, chalk);
101
107
  // legacy aliases kept for compatibility
102
108
  case 'up': return upCommand(config, rest, chalk);
103
109
  case 'config': {
@@ -2,7 +2,6 @@ import readline from 'readline';
2
2
  import { requireApiKey } from '../config.js';
3
3
  import { callApi, terminateDeployment } from '../api.js';
4
4
  import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
5
- import { rankAlternatives, promptFallback } from '../fallback.js';
6
5
 
7
6
  /**
8
7
  * badgr run python train.py # gpu=auto, attached
@@ -270,12 +269,53 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
270
269
  }
271
270
  }
272
271
 
273
- // Ask the backend for the best available GPU right now.
274
- // Defaults to tier 1 (RunPod) for reliability; pass tier='2' for budget providers.
275
- // Returns { gpu, region, price } or null when nothing is available.
276
- async function findAutoGpu(config, chalk, tier = '1') {
272
+ /**
273
+ * Infer a workload profile name from the command the user wants to run.
274
+ * The backend uses this to enforce a VRAM floor when picking a GPU.
275
+ *
276
+ * Note: \b word boundaries are intentionally avoided for keyword checks
277
+ * because keywords commonly appear inside filenames joined by underscores
278
+ * (e.g. lora_train.py, run_sdxl.py) where `_` is a word character.
279
+ */
280
+ export function inferWorkload(command) {
281
+ if (!command || command.length === 0) return 'general';
282
+
283
+ const cmd = command.join(' ').toLowerCase();
284
+
285
+ // Trivial one-liner / smoke test
286
+ if (/print\s*\(|['"]hello/.test(cmd) && cmd.length < 80) return 'smoke_test';
287
+
288
+ // LoRA / QLoRA / PEFT fine-tuning
289
+ if (/lora|qlora|finetune|fine[_-]tun|peft/.test(cmd)) return 'lora_finetune';
290
+
291
+ // Diffusion / image generation
292
+ if (/diffusion|stable.?diff|sdxl|sd.?xl|comfyui|a1111|invoke|kohya/.test(cmd)) return 'image_gen';
293
+
294
+ // vLLM / TGI / inference server
295
+ if (/vllm|[^a-z]tgi[^a-z]|^tgi\b|tgi$|text.generation.inference/.test(cmd)) return 'inference_small';
296
+
297
+ // Explicit training script
298
+ if (/\btrain\.py\b/.test(cmd)) return 'lora_finetune';
299
+
300
+ return 'general';
301
+ }
302
+
303
+ const WORKLOAD_LABELS = {
304
+ smoke_test: 'smoke test',
305
+ general: 'GPU job',
306
+ lora_finetune: 'fine-tuning (40GB+ VRAM)',
307
+ image_gen: 'image generation',
308
+ inference_small: 'inference (7B–8B model)',
309
+ inference_medium: 'inference (30B–34B model)',
310
+ inference_large: 'inference (70B+ model)',
311
+ };
312
+
313
+ // Ask the backend for the best available GPU for a given workload.
314
+ // Routes by: workload → min VRAM → provider tier → cheapest match.
315
+ // Returns { gpu, region, price, workload, workload_desc } or null when nothing is available.
316
+ async function findAutoGpu(config, chalk, tier = '1', workload = 'general') {
277
317
  try {
278
- const params = new URLSearchParams({ max_price: '10', tier });
318
+ const params = new URLSearchParams({ max_price: '10', tier, workload });
279
319
  return await callApi(`/capacity/auto?${params}`, {
280
320
  apiKey: config.apiKey,
281
321
  baseUrl: config.baseUrl,
@@ -319,17 +359,21 @@ export async function runCommand(config, args, chalk) {
319
359
  let gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : null;
320
360
  let autoRegion = null;
321
361
 
362
+ // Infer workload from the command so the backend can apply the correct VRAM floor.
363
+ const workload = command ? inferWorkload(command) : 'general';
364
+ const workloadLabel = WORKLOAD_LABELS[workload] || 'GPU job';
365
+
322
366
  if (!gpu) {
323
- console.log(chalk.bold('\n⚡ Running GPU job\n'));
367
+ console.log(chalk.bold(`\n⚡ Running ${workloadLabel}\n`));
324
368
  if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
325
369
  if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
326
370
  if (effectiveTier === '2') console.log(` ${chalk.dim('(budget mode — searching all providers)')}`);
327
371
  console.log();
328
- process.stdout.write(chalk.dim(' Finding GPU...'));
372
+ process.stdout.write(chalk.dim(' Finding best GPU...'));
329
373
 
330
374
  let best;
331
375
  try {
332
- best = await findAutoGpu(config, chalk, effectiveTier);
376
+ best = await findAutoGpu(config, chalk, effectiveTier, workload);
333
377
  } catch (err) {
334
378
  process.stdout.write('\n');
335
379
  console.error(chalk.red(`\n ✗ Could not find GPU capacity: ${err.message}\n`));
@@ -344,17 +388,17 @@ export async function runCommand(config, args, chalk) {
344
388
  process.exit(1);
345
389
  }
346
390
 
347
- console.log(`\n ${chalk.bold('Best match:')} ${chalk.cyan(best.gpu)} in ${best.region} — ${chalk.green('$' + best.price.toFixed(2) + '/hr')} estimated`);
391
+ const vramNote = best.min_vram_gb ? chalk.dim(` (${best.min_vram_gb}GB VRAM)`) : '';
392
+ console.log(`\n ${chalk.bold('Selected:')} ${chalk.cyan(best.gpu)} in ${best.region} — ${chalk.green('$' + best.price.toFixed(2) + '/hr')}${vramNote}`);
348
393
  console.log();
349
394
 
350
- if (process.stdin.isTTY) {
351
- const answer = await askConfirm(` Press ${chalk.bold('Enter')} to run, or ${chalk.bold('q')} to cancel: `);
395
+ if (effectiveTier === '2' && process.stdin.isTTY) {
396
+ // Budget mode: confirm because the user is being routed to a less reliable provider.
397
+ const answer = await askConfirm(` Press ${chalk.bold('Enter')} to run on budget provider, or ${chalk.bold('q')} to cancel: `);
352
398
  if (answer.toLowerCase() === 'q') {
353
399
  console.log(chalk.dim('\n Cancelled.\n'));
354
400
  process.exit(0);
355
401
  }
356
- } else {
357
- console.log(chalk.dim(` Auto-selecting ${best.gpu} (non-interactive).`));
358
402
  }
359
403
 
360
404
  gpu = best.gpu;
@@ -409,56 +453,48 @@ export async function runCommand(config, args, chalk) {
409
453
  } catch (err) {
410
454
  const d = err.errorData;
411
455
  if (d?.code === 'NO_CAPACITY_MATCH') {
412
- if (fallbackMode === 'none') {
413
- console.error(chalk.red(`\n ✗ ${gpu} isn't available right now.\n`));
414
- console.error(chalk.dim(' Pass --fallback closest to auto-select an alternative.'));
415
- process.exit(1);
416
- }
417
-
418
- const pool = [
419
- ...(Array.isArray(d.same_gpu_other_regions) ? d.same_gpu_other_regions : []),
420
- ...(Array.isArray(d.alternatives) ? d.alternatives : []),
421
- ];
422
-
423
- if (pool.length === 0) {
424
- console.error(chalk.red(`\n ✗ ${gpu} isn't available right now and no alternatives were found.\n`));
456
+ if (effectiveTier === '2') {
457
+ console.error(chalk.red('\n ✗ No GPU capacity available right now on any provider.\n'));
425
458
  console.error(chalk.dim(' Try `badgr capacity` to see what\'s available, or try again shortly.'));
426
459
  process.exit(1);
427
460
  }
428
461
 
429
- const ranked = rankAlternatives(gpu, pool, fallbackMode);
430
- const chosen = await promptFallback(gpu, ranked, chalk);
431
-
432
- if (!chosen) {
433
- console.log(chalk.dim('\n Cancelled.\n'));
434
- process.exit(0);
462
+ // Tier 1 (RunPod) is out of capacity — offer budget providers.
463
+ if (process.stdin.isTTY) {
464
+ const answer = await askConfirm(
465
+ `\n RunPod has no suitable capacity. Try budget marketplace GPUs? (Vast.ai, Salad) [${chalk.bold('Enter')}/${chalk.bold('q')}]: `
466
+ );
467
+ if (answer.toLowerCase() === 'q') {
468
+ console.log(chalk.dim('\n Cancelled.\n'));
469
+ process.exit(0);
470
+ }
471
+ } else {
472
+ console.log(chalk.dim('\n RunPod capacity unavailable — falling back to budget providers...\n'));
435
473
  }
436
474
 
437
- console.log(chalk.dim(`\n Trying ${chosen.gpu} in ${chosen.region}...\n`));
438
-
475
+ console.log(chalk.dim(' Searching budget providers (Vast.ai, Salad)...'));
439
476
  try {
440
477
  dep = await callApi('/run', {
441
478
  method: 'POST',
442
479
  apiKey: config.apiKey,
443
480
  baseUrl: config.baseUrl,
444
- body: buildBody(chosen.gpu, chosen.region),
481
+ body: { ...buildBody(), tier: '2' },
445
482
  });
446
483
  } catch (err2) {
447
484
  const d2 = err2.errorData;
448
- if (d2?.code === 'PROVISIONING_FAILED' || d2?.code === 'PROVIDER_ADAPTER_ERROR') {
449
- if (d2?.low_cost_provider_failed) {
450
- console.error(chalk.red(`\n Tier 2 unavailable. Tier 1 also unavailable. Try again shortly.\n`));
451
- } else {
452
- console.error(chalk.red(`\n ✗ Badgr found ${chosen.gpu} capacity but could not start the machine. Please try again.\n`));
453
- }
485
+ if (d2?.code === 'NO_CAPACITY_MATCH') {
486
+ console.error(chalk.red('\n ✗ No GPU capacity available right now on any provider.\n'));
487
+ console.error(chalk.dim(' Try `badgr capacity` to see what\'s available, or try again shortly.'));
488
+ } else if (d2?.code === 'PROVISIONING_FAILED' || d2?.code === 'PROVIDER_ADAPTER_ERROR') {
489
+ console.error(chalk.red(`\n ✗ Budget provider found capacity but could not start the machine. Please try again.\n`));
454
490
  if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
455
491
  if (d2?.debug_error) console.error(chalk.dim(` Provider detail: ${d2.debug_error}`));
492
+ } else {
493
+ console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.\n`));
456
494
  }
457
495
  } else {
458
- console.error(chalk.red(`\n ✗ Job failed to start on ${chosen.gpu}: ${err2.message}\n`));
459
- }
460
- if (!(process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true')) {
461
- console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.\n`));
496
+ console.error(chalk.red(`\n ✗ Could not start job on budget providers: ${err2.message}\n`));
497
+ console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.`));
462
498
  }
463
499
  process.exit(1);
464
500
  }
@@ -1,7 +1,12 @@
1
+ import readline from 'readline';
1
2
  import { requireApiKey } from '../config.js';
2
3
  import { callApi } from '../api.js';
3
4
  import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../store.js';
4
- import { rankAlternatives, promptFallback } from '../fallback.js';
5
+
6
+ function askConfirm(prompt) {
7
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
8
+ return new Promise(resolve => rl.question(prompt, ans => { rl.close(); resolve(ans.trim()); }));
9
+ }
5
10
 
6
11
  /**
7
12
  * badgr serve meta-llama/Llama-3.1-8B-Instruct
@@ -28,21 +33,26 @@ export function parseServeArgs(args) {
28
33
  return { model, flags };
29
34
  }
30
35
 
36
+ function _serveStageLabel(elapsedSec) {
37
+ if (elapsedSec < 45) return 'Starting vLLM…';
38
+ if (elapsedSec < 150) return 'Downloading model…';
39
+ return 'Waiting for /v1/models…';
40
+ }
41
+
31
42
  async function waitForEndpoint(endpointUrl, timeoutMs = 5 * 60 * 1000, chalk) {
32
- const deadline = Date.now() + timeoutMs;
33
- let attempt = 0;
43
+ const startMs = Date.now();
44
+ const deadline = startMs + timeoutMs;
34
45
 
35
46
  while (Date.now() < deadline) {
36
- attempt++;
37
47
  try {
38
48
  const res = await fetch(`${endpointUrl}/models`, { signal: AbortSignal.timeout(8000) });
39
- if (res.ok) return true;
49
+ if (res.ok) { process.stdout.write('\n'); return true; }
40
50
  } catch {
41
51
  // still starting
42
52
  }
43
- const elapsed = Math.round((Date.now() - (deadline - timeoutMs)) / 1000);
53
+ const elapsed = Math.round((Date.now() - startMs) / 1000);
44
54
  process.stdout.write(
45
- `\r ${chalk.dim(`Health-checking /v1/models… ${elapsed}s`)} `
55
+ `\r ${chalk.dim(_serveStageLabel(elapsed) + ` (${elapsed}s)`)} `
46
56
  );
47
57
  await new Promise(r => setTimeout(r, 8000));
48
58
  }
@@ -102,46 +112,45 @@ export async function serveCommand(config, args, chalk) {
102
112
  } catch (err) {
103
113
  const d = err.errorData;
104
114
  if (d?.code === 'NO_CAPACITY_MATCH') {
105
- const pool = [
106
- ...(Array.isArray(d.same_gpu_other_regions) ? d.same_gpu_other_regions : []),
107
- ...(Array.isArray(d.alternatives) ? d.alternatives : []),
108
- ];
109
-
110
- if (pool.length === 0) {
111
- console.error(chalk.red(`\n ✗ No GPU capacity available right now.\n`));
115
+ if (effectiveTier === '2') {
116
+ console.error(chalk.red('\n ✗ No GPU capacity available right now on any provider.\n'));
112
117
  console.error(chalk.dim(' Run `badgr capacity` to see alternatives, or try again shortly.'));
113
118
  process.exit(1);
114
119
  }
115
120
 
116
- const ranked = rankAlternatives(gpuLabel, pool, 'closest');
117
- const chosen = await promptFallback(gpuLabel, ranked, chalk);
118
-
119
- if (!chosen) {
120
- console.log(chalk.dim('\n Cancelled.\n'));
121
- process.exit(0);
121
+ // Tier 1 (RunPod) out of capacity — offer budget providers.
122
+ if (process.stdin.isTTY) {
123
+ const answer = await askConfirm(
124
+ `\n RunPod has no suitable capacity. Try budget marketplace GPUs? (Vast.ai, Salad) [${chalk.bold('Enter')}/${chalk.bold('q')}]: `
125
+ );
126
+ if (answer.toLowerCase() === 'q') {
127
+ console.log(chalk.dim('\n Cancelled.\n'));
128
+ process.exit(0);
129
+ }
130
+ } else {
131
+ console.log(chalk.dim('\n RunPod capacity unavailable — falling back to budget providers...\n'));
122
132
  }
123
133
 
124
- console.log(chalk.dim(`\n Trying ${chosen.gpu} in ${chosen.region}...\n`));
125
-
134
+ console.log(chalk.dim(' Searching budget providers (Vast.ai, Salad)...'));
126
135
  try {
127
136
  dep = await callApi('/serve', {
128
137
  method: 'POST',
129
138
  apiKey: config.apiKey,
130
139
  baseUrl: config.baseUrl,
131
- body: buildBody(chosen.gpu, chosen.region),
140
+ body: { ...buildBody(), tier: '2' },
132
141
  });
133
142
  } catch (err2) {
134
143
  const d2 = err2.errorData;
135
- if (d2?.code === 'PROVISIONING_FAILED' || d2?.code === 'PROVIDER_ADAPTER_ERROR') {
136
- if (d2?.low_cost_provider_failed) {
137
- console.error(chalk.red(`\n Tier 2 unavailable. Tier 1 also unavailable. Try again shortly.\n`));
138
- } else {
139
- console.error(chalk.red(`\n ✗ Badgr found ${chosen.gpu} capacity but could not start the endpoint. Please try again.\n`));
140
- }
144
+ if (d2?.code === 'NO_CAPACITY_MATCH') {
145
+ console.error(chalk.red('\n ✗ No GPU capacity available right now on any provider.\n'));
146
+ console.error(chalk.dim(' Run `badgr capacity` to see alternatives, or try again shortly.'));
147
+ } else if (d2?.code === 'PROVISIONING_FAILED' || d2?.code === 'PROVIDER_ADAPTER_ERROR') {
148
+ console.error(chalk.red(`\n ✗ Budget provider found capacity but could not start the endpoint. Please try again.\n`));
149
+ console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
141
150
  } else {
142
- console.error(chalk.red(`\n ✗ Failed to start endpoint on ${chosen.gpu}: ${err2.message}\n`));
151
+ console.error(chalk.red(`\n ✗ Failed to start endpoint on budget providers: ${err2.message}\n`));
152
+ console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
143
153
  }
144
- console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
145
154
  process.exit(1);
146
155
  }
147
156
  } else if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
@@ -212,12 +221,11 @@ export async function serveCommand(config, args, chalk) {
212
221
 
213
222
  // ── Result ────────────────────────────────────────────────────────────────
214
223
  if (endpointReady) {
215
- console.log(chalk.green('\n✓ Endpoint ready\n'));
224
+ console.log(chalk.green('\n ✓ Endpoint ready\n'));
216
225
  } else {
217
- console.log(chalk.yellow('\n⏳ Endpoint still starting\n'));
218
- console.log(chalk.dim(' The deployment is provisioned but not yet responding.'));
219
- console.log(chalk.dim(` Check: badgr logs ${dep.deployment_id}`));
220
- console.log(chalk.dim(` Stop if it never starts: badgr down ${dep.deployment_id}`));
226
+ console.log(chalk.yellow('\n ⏳ Endpoint still starting — model download may still be in progress.\n'));
227
+ console.log(` ${chalk.bold('Stop billing now:')} ${chalk.dim(`badgr down ${dep.deployment_id}`)}`);
228
+ console.log(` ${chalk.bold('Continue watching:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
221
229
  console.log();
222
230
  }
223
231
 
@@ -47,7 +47,7 @@ export async function statusCommand(config, args, chalk) {
47
47
 
48
48
  console.log(chalk.bold('\nRunning now:\n'));
49
49
  for (const d of running) {
50
- const id = d.name || d.deployment_id;
50
+ const id = d.deployment_id || d.name;
51
51
  const type = d.workload_type === 'endpoint' ? 'endpoint' : 'job';
52
52
  const gpu = d.gpu_type || '—';
53
53
  const rate = d.cost_per_hour > 0 ? chalk.yellow(`$${d.cost_per_hour.toFixed(2)}/hr`) : '';
@@ -74,7 +74,7 @@ export async function statusCommand(config, args, chalk) {
74
74
 
75
75
  console.log(chalk.bold('Stop billing:\n'));
76
76
  for (const d of running) {
77
- const id = d.name || d.deployment_id;
77
+ const id = d.deployment_id || d.name;
78
78
  console.log(` ${chalk.cyan(`badgr down ${id}`)}`);
79
79
  }
80
80
  console.log();
@@ -0,0 +1,165 @@
1
+ import { requireApiKey } from '../config.js';
2
+ import { callApi, terminateDeployment } from '../api.js';
3
+ import { addReceipt, generateReceiptId } from '../store.js';
4
+
5
+ // max $1.50/hr × 2 min ≈ $0.05 total spend cap
6
+ const TEST_MAX_PRICE = 1.50;
7
+ const TEST_MAX_RUNTIME_MS = 2 * 60 * 1000;
8
+ const TEST_COMMAND = ['python', '-c', "print('hello from badgr')"];
9
+ const TEST_IMAGE = 'python:3.11-slim';
10
+ const EXPECTED_OUTPUT = 'hello from badgr';
11
+
12
+ function step(chalk, ok, msg, detail = '') {
13
+ const icon = ok ? chalk.green('✓') : chalk.red('✗');
14
+ const suffix = detail ? chalk.dim(` — ${detail}`) : '';
15
+ console.log(` ${icon} ${msg}${suffix}`);
16
+ }
17
+
18
+ async function pollStatus(config, depId, targetStatuses, timeoutMs) {
19
+ const deadline = Date.now() + timeoutMs;
20
+ while (Date.now() < deadline) {
21
+ await new Promise(r => setTimeout(r, 3000));
22
+ try {
23
+ const dep = await callApi(`/deployments/${depId}`, {
24
+ apiKey: config.apiKey,
25
+ baseUrl: config.baseUrl,
26
+ });
27
+ if (targetStatuses.has(dep.status)) return dep;
28
+ } catch { /* retry */ }
29
+ }
30
+ return null;
31
+ }
32
+
33
+ async function pollLogs(config, depId, expected, timeoutMs) {
34
+ const deadline = Date.now() + timeoutMs;
35
+ while (Date.now() < deadline) {
36
+ await new Promise(r => setTimeout(r, 4000));
37
+ try {
38
+ const data = await callApi(`/deployments/${depId}/logs`, {
39
+ apiKey: config.apiKey,
40
+ baseUrl: config.baseUrl,
41
+ });
42
+ const lines = data?.logs ?? [];
43
+ if (lines.some(l => l.includes(expected))) return true;
44
+ } catch { /* retry */ }
45
+ }
46
+ return false;
47
+ }
48
+
49
+ export async function testCommand(config, chalk) {
50
+ requireApiKey(config);
51
+
52
+ console.log(chalk.bold('\n⚡ Running end-to-end test\n'));
53
+ console.log(chalk.dim(` Command: ${TEST_COMMAND.join(' ')}`));
54
+ console.log(chalk.dim(` Provider: RunPod (Tier 1 — reliable)`));
55
+ console.log(chalk.dim(` Budget: max $${TEST_MAX_PRICE.toFixed(2)}/hr · 2 minute cap (~$0.05 max)`));
56
+ console.log();
57
+
58
+ const rcptId = generateReceiptId();
59
+ let depId;
60
+
61
+ // ── 1. Provision ─────────────────────────────────────────────────────────
62
+ process.stdout.write(chalk.dim(' Provisioning GPU (RunPod)...'));
63
+ let dep;
64
+ const baseBody = {
65
+ command: TEST_COMMAND,
66
+ image: TEST_IMAGE,
67
+ gpu: 'RTX_3080',
68
+ max_price_per_hour: TEST_MAX_PRICE,
69
+ };
70
+ try {
71
+ dep = await callApi('/run', {
72
+ method: 'POST',
73
+ apiKey: config.apiKey,
74
+ baseUrl: config.baseUrl,
75
+ body: { ...baseBody, tier: '1' },
76
+ });
77
+ } catch (err) {
78
+ if (err.errorData?.code === 'NO_CAPACITY_MATCH') {
79
+ process.stdout.write('\n');
80
+ process.stdout.write(chalk.dim(' RunPod unavailable, trying budget providers...'));
81
+ try {
82
+ dep = await callApi('/run', {
83
+ method: 'POST',
84
+ apiKey: config.apiKey,
85
+ baseUrl: config.baseUrl,
86
+ body: { ...baseBody, tier: '2' },
87
+ });
88
+ } catch (err2) {
89
+ process.stdout.write('\n');
90
+ step(chalk, false, 'Provisioned', err2.message);
91
+ console.log();
92
+ console.error(chalk.red(' Test failed — no GPU capacity available on any provider.\n'));
93
+ process.exit(1);
94
+ }
95
+ } else {
96
+ process.stdout.write('\n');
97
+ step(chalk, false, 'Provisioned', err.message);
98
+ console.log();
99
+ console.error(chalk.red(' Test failed — could not provision GPU.\n'));
100
+ process.exit(1);
101
+ }
102
+ }
103
+ depId = dep.deployment_id;
104
+ process.stdout.write('\n');
105
+ step(chalk, true, 'Provisioned', `${dep.deployment_id} on ${dep.gpu_type}`);
106
+
107
+ // ── 2. Container started ─────────────────────────────────────────────────
108
+ process.stdout.write(chalk.dim(' Waiting for container to start...'));
109
+ const started = await pollStatus(
110
+ config, depId,
111
+ new Set(['running', 'failed', 'stopped', 'completed']),
112
+ TEST_MAX_RUNTIME_MS,
113
+ );
114
+ process.stdout.write('\n');
115
+
116
+ if (!started || started.status === 'failed') {
117
+ step(chalk, false, 'Container started', started?.status ?? 'timeout');
118
+ console.log();
119
+ console.error(chalk.red(' Test failed — container did not start.\n'));
120
+ try { await terminateDeployment(config, depId); } catch { /* best-effort */ }
121
+ process.exit(1);
122
+ }
123
+ step(chalk, true, 'Container started');
124
+
125
+ // ── 3. Command output ────────────────────────────────────────────────────
126
+ process.stdout.write(chalk.dim(' Checking command output...'));
127
+ const gotOutput = await pollLogs(config, depId, EXPECTED_OUTPUT, 90_000);
128
+ process.stdout.write('\n');
129
+ if (gotOutput) {
130
+ step(chalk, true, 'Command printed output');
131
+ } else {
132
+ step(chalk, false, 'Command printed output', 'not found in logs (logs may be buffered)');
133
+ }
134
+
135
+ // ── 4. Stop billing ──────────────────────────────────────────────────────
136
+ process.stdout.write(chalk.dim(' Stopping deployment...'));
137
+ let stopped = false;
138
+ try {
139
+ await terminateDeployment(config, depId);
140
+ stopped = true;
141
+ } catch { /* best-effort */ }
142
+ process.stdout.write('\n');
143
+ step(chalk, stopped, 'Billing stopped');
144
+
145
+ // ── 5. Receipt ───────────────────────────────────────────────────────────
146
+ addReceipt({
147
+ receiptId: rcptId,
148
+ action: 'badgr test',
149
+ deploymentId: depId,
150
+ gpu: dep.gpu_type,
151
+ status: 'test_complete',
152
+ createdAt: new Date().toISOString(),
153
+ });
154
+ step(chalk, true, 'Receipt created', rcptId);
155
+
156
+ // ── Summary ──────────────────────────────────────────────────────────────
157
+ console.log();
158
+ const passed = stopped;
159
+ if (passed) {
160
+ console.log(chalk.green(chalk.bold(' ✓ Test passed\n')));
161
+ } else {
162
+ console.log(chalk.red(chalk.bold(' ✗ Test failed\n')));
163
+ process.exit(1);
164
+ }
165
+ }
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
- import { parseRunArgs, classifyFailure } from '../src/commands/run.js';
2
+ import { parseRunArgs, classifyFailure, inferWorkload } from '../src/commands/run.js';
3
3
  import { parseServeArgs } from '../src/commands/serve.js';
4
+ import { testCommand } from '../src/commands/test-run.js';
4
5
  import { rankAlternatives, diffDescription, promptFallback } from '../src/fallback.js';
5
6
 
6
7
  describe('parseRunArgs', () => {
@@ -214,6 +215,45 @@ describe('parseServeArgs', () => {
214
215
  });
215
216
  });
216
217
 
218
+ describe('testCommand', () => {
219
+ it('is a function', () => {
220
+ expect(typeof testCommand).toBe('function');
221
+ });
222
+ });
223
+
224
+ describe('inferWorkload', () => {
225
+ it('returns general for empty command', () => {
226
+ expect(inferWorkload([])).toBe('general');
227
+ expect(inferWorkload(null)).toBe('general');
228
+ });
229
+
230
+ it('returns smoke_test for short print() one-liners', () => {
231
+ expect(inferWorkload(['python', '-c', "print('hello')"])).toBe('smoke_test');
232
+ expect(inferWorkload(['python', '-c', "print('hello from badgr')"])).toBe('smoke_test');
233
+ });
234
+
235
+ it('returns general for a typical script', () => {
236
+ expect(inferWorkload(['python', 'script.py'])).toBe('general');
237
+ expect(inferWorkload(['python', 'run.py', '--epochs', '10'])).toBe('general');
238
+ });
239
+
240
+ it('returns lora_finetune for LoRA/fine-tuning keywords', () => {
241
+ expect(inferWorkload(['python', 'lora_train.py'])).toBe('lora_finetune');
242
+ expect(inferWorkload(['python', '-c', 'import peft; finetune()'])).toBe('lora_finetune');
243
+ expect(inferWorkload(['python', 'train.py', '--epochs', '3'])).toBe('lora_finetune');
244
+ });
245
+
246
+ it('returns image_gen for diffusion keywords', () => {
247
+ expect(inferWorkload(['python', 'stable_diff.py'])).toBe('image_gen');
248
+ expect(inferWorkload(['python', 'run_sdxl.py'])).toBe('image_gen');
249
+ });
250
+
251
+ it('returns inference_small for vllm/tgi', () => {
252
+ expect(inferWorkload(['python', '-m', 'vllm.entrypoints.openai.api_server'])).toBe('inference_small');
253
+ expect(inferWorkload(['python', 'serve_tgi.py'])).toBe('inference_small');
254
+ });
255
+ });
256
+
217
257
  describe('promptFallback output', () => {
218
258
  it('does not print a Difference line', async () => {
219
259
  const lines = [];