badgr-cli 1.0.21 → 1.0.22

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.22",
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': {
@@ -270,12 +270,53 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
270
270
  }
271
271
  }
272
272
 
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') {
273
+ /**
274
+ * Infer a workload profile name from the command the user wants to run.
275
+ * The backend uses this to enforce a VRAM floor when picking a GPU.
276
+ *
277
+ * Note: \b word boundaries are intentionally avoided for keyword checks
278
+ * because keywords commonly appear inside filenames joined by underscores
279
+ * (e.g. lora_train.py, run_sdxl.py) where `_` is a word character.
280
+ */
281
+ export function inferWorkload(command) {
282
+ if (!command || command.length === 0) return 'general';
283
+
284
+ const cmd = command.join(' ').toLowerCase();
285
+
286
+ // Trivial one-liner / smoke test
287
+ if (/print\s*\(|['"]hello/.test(cmd) && cmd.length < 80) return 'smoke_test';
288
+
289
+ // LoRA / QLoRA / PEFT fine-tuning
290
+ if (/lora|qlora|finetune|fine[_-]tun|peft/.test(cmd)) return 'lora_finetune';
291
+
292
+ // Diffusion / image generation
293
+ if (/diffusion|stable.?diff|sdxl|sd.?xl|comfyui|a1111|invoke|kohya/.test(cmd)) return 'image_gen';
294
+
295
+ // vLLM / TGI / inference server
296
+ if (/vllm|[^a-z]tgi[^a-z]|^tgi\b|tgi$|text.generation.inference/.test(cmd)) return 'inference_small';
297
+
298
+ // Explicit training script
299
+ if (/\btrain\.py\b/.test(cmd)) return 'lora_finetune';
300
+
301
+ return 'general';
302
+ }
303
+
304
+ const WORKLOAD_LABELS = {
305
+ smoke_test: 'smoke test',
306
+ general: 'GPU job',
307
+ lora_finetune: 'fine-tuning (40GB+ VRAM)',
308
+ image_gen: 'image generation',
309
+ inference_small: 'inference (7B–8B model)',
310
+ inference_medium: 'inference (30B–34B model)',
311
+ inference_large: 'inference (70B+ model)',
312
+ };
313
+
314
+ // Ask the backend for the best available GPU for a given workload.
315
+ // Routes by: workload → min VRAM → provider tier → cheapest match.
316
+ // Returns { gpu, region, price, workload, workload_desc } or null when nothing is available.
317
+ async function findAutoGpu(config, chalk, tier = '1', workload = 'general') {
277
318
  try {
278
- const params = new URLSearchParams({ max_price: '10', tier });
319
+ const params = new URLSearchParams({ max_price: '10', tier, workload });
279
320
  return await callApi(`/capacity/auto?${params}`, {
280
321
  apiKey: config.apiKey,
281
322
  baseUrl: config.baseUrl,
@@ -319,17 +360,21 @@ export async function runCommand(config, args, chalk) {
319
360
  let gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : null;
320
361
  let autoRegion = null;
321
362
 
363
+ // Infer workload from the command so the backend can apply the correct VRAM floor.
364
+ const workload = command ? inferWorkload(command) : 'general';
365
+ const workloadLabel = WORKLOAD_LABELS[workload] || 'GPU job';
366
+
322
367
  if (!gpu) {
323
- console.log(chalk.bold('\n⚡ Running GPU job\n'));
368
+ console.log(chalk.bold(`\n⚡ Running ${workloadLabel}\n`));
324
369
  if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
325
370
  if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
326
371
  if (effectiveTier === '2') console.log(` ${chalk.dim('(budget mode — searching all providers)')}`);
327
372
  console.log();
328
- process.stdout.write(chalk.dim(' Finding GPU...'));
373
+ process.stdout.write(chalk.dim(' Finding best GPU...'));
329
374
 
330
375
  let best;
331
376
  try {
332
- best = await findAutoGpu(config, chalk, effectiveTier);
377
+ best = await findAutoGpu(config, chalk, effectiveTier, workload);
333
378
  } catch (err) {
334
379
  process.stdout.write('\n');
335
380
  console.error(chalk.red(`\n ✗ Could not find GPU capacity: ${err.message}\n`));
@@ -344,17 +389,17 @@ export async function runCommand(config, args, chalk) {
344
389
  process.exit(1);
345
390
  }
346
391
 
347
- console.log(`\n ${chalk.bold('Best match:')} ${chalk.cyan(best.gpu)} in ${best.region} — ${chalk.green('$' + best.price.toFixed(2) + '/hr')} estimated`);
392
+ const vramNote = best.min_vram_gb ? chalk.dim(` (${best.min_vram_gb}GB VRAM)`) : '';
393
+ console.log(`\n ${chalk.bold('Selected:')} ${chalk.cyan(best.gpu)} in ${best.region} — ${chalk.green('$' + best.price.toFixed(2) + '/hr')}${vramNote}`);
348
394
  console.log();
349
395
 
350
- if (process.stdin.isTTY) {
351
- const answer = await askConfirm(` Press ${chalk.bold('Enter')} to run, or ${chalk.bold('q')} to cancel: `);
396
+ if (effectiveTier === '2' && process.stdin.isTTY) {
397
+ // Budget mode: confirm because the user is being routed to a less reliable provider.
398
+ const answer = await askConfirm(` Press ${chalk.bold('Enter')} to run on budget provider, or ${chalk.bold('q')} to cancel: `);
352
399
  if (answer.toLowerCase() === 'q') {
353
400
  console.log(chalk.dim('\n Cancelled.\n'));
354
401
  process.exit(0);
355
402
  }
356
- } else {
357
- console.log(chalk.dim(` Auto-selecting ${best.gpu} (non-interactive).`));
358
403
  }
359
404
 
360
405
  gpu = best.gpu;
@@ -28,21 +28,26 @@ export function parseServeArgs(args) {
28
28
  return { model, flags };
29
29
  }
30
30
 
31
+ function _serveStageLabel(elapsedSec) {
32
+ if (elapsedSec < 45) return 'Starting vLLM…';
33
+ if (elapsedSec < 150) return 'Downloading model…';
34
+ return 'Waiting for /v1/models…';
35
+ }
36
+
31
37
  async function waitForEndpoint(endpointUrl, timeoutMs = 5 * 60 * 1000, chalk) {
32
- const deadline = Date.now() + timeoutMs;
33
- let attempt = 0;
38
+ const startMs = Date.now();
39
+ const deadline = startMs + timeoutMs;
34
40
 
35
41
  while (Date.now() < deadline) {
36
- attempt++;
37
42
  try {
38
43
  const res = await fetch(`${endpointUrl}/models`, { signal: AbortSignal.timeout(8000) });
39
- if (res.ok) return true;
44
+ if (res.ok) { process.stdout.write('\n'); return true; }
40
45
  } catch {
41
46
  // still starting
42
47
  }
43
- const elapsed = Math.round((Date.now() - (deadline - timeoutMs)) / 1000);
48
+ const elapsed = Math.round((Date.now() - startMs) / 1000);
44
49
  process.stdout.write(
45
- `\r ${chalk.dim(`Health-checking /v1/models… ${elapsed}s`)} `
50
+ `\r ${chalk.dim(_serveStageLabel(elapsed) + ` (${elapsed}s)`)} `
46
51
  );
47
52
  await new Promise(r => setTimeout(r, 8000));
48
53
  }
@@ -212,12 +217,11 @@ export async function serveCommand(config, args, chalk) {
212
217
 
213
218
  // ── Result ────────────────────────────────────────────────────────────────
214
219
  if (endpointReady) {
215
- console.log(chalk.green('\n✓ Endpoint ready\n'));
220
+ console.log(chalk.green('\n ✓ Endpoint ready\n'));
216
221
  } 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}`));
222
+ console.log(chalk.yellow('\n ⏳ Endpoint still starting — model download may still be in progress.\n'));
223
+ console.log(` ${chalk.bold('Stop billing now:')} ${chalk.dim(`badgr down ${dep.deployment_id}`)}`);
224
+ console.log(` ${chalk.bold('Continue watching:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
221
225
  console.log();
222
226
  }
223
227
 
@@ -0,0 +1,147 @@
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...'));
63
+ let dep;
64
+ try {
65
+ dep = await callApi('/run', {
66
+ method: 'POST',
67
+ apiKey: config.apiKey,
68
+ baseUrl: config.baseUrl,
69
+ body: {
70
+ command: TEST_COMMAND,
71
+ image: TEST_IMAGE,
72
+ tier: '1',
73
+ // smoke_test workload → cheapest reliable GPU with ≥4 GB VRAM
74
+ gpu: 'RTX_3080',
75
+ max_price_per_hour: TEST_MAX_PRICE,
76
+ },
77
+ });
78
+ depId = dep.deployment_id;
79
+ process.stdout.write('\n');
80
+ step(chalk, true, 'Provisioned', `${dep.deployment_id} on ${dep.gpu_type}`);
81
+ } catch (err) {
82
+ process.stdout.write('\n');
83
+ step(chalk, false, 'Provisioned', err.message);
84
+ console.log();
85
+ console.error(chalk.red(' Test failed — could not provision GPU.\n'));
86
+ process.exit(1);
87
+ }
88
+
89
+ // ── 2. Container started ─────────────────────────────────────────────────
90
+ process.stdout.write(chalk.dim(' Waiting for container to start...'));
91
+ const started = await pollStatus(
92
+ config, depId,
93
+ new Set(['running', 'failed', 'stopped', 'completed']),
94
+ TEST_MAX_RUNTIME_MS,
95
+ );
96
+ process.stdout.write('\n');
97
+
98
+ if (!started || started.status === 'failed') {
99
+ step(chalk, false, 'Container started', started?.status ?? 'timeout');
100
+ console.log();
101
+ console.error(chalk.red(' Test failed — container did not start.\n'));
102
+ try { await terminateDeployment(config, depId); } catch { /* best-effort */ }
103
+ process.exit(1);
104
+ }
105
+ step(chalk, true, 'Container started');
106
+
107
+ // ── 3. Command output ────────────────────────────────────────────────────
108
+ process.stdout.write(chalk.dim(' Checking command output...'));
109
+ const gotOutput = await pollLogs(config, depId, EXPECTED_OUTPUT, 90_000);
110
+ process.stdout.write('\n');
111
+ if (gotOutput) {
112
+ step(chalk, true, 'Command printed output');
113
+ } else {
114
+ step(chalk, false, 'Command printed output', 'not found in logs (logs may be buffered)');
115
+ }
116
+
117
+ // ── 4. Stop billing ──────────────────────────────────────────────────────
118
+ process.stdout.write(chalk.dim(' Stopping deployment...'));
119
+ let stopped = false;
120
+ try {
121
+ await terminateDeployment(config, depId);
122
+ stopped = true;
123
+ } catch { /* best-effort */ }
124
+ process.stdout.write('\n');
125
+ step(chalk, stopped, 'Billing stopped');
126
+
127
+ // ── 5. Receipt ───────────────────────────────────────────────────────────
128
+ addReceipt({
129
+ receiptId: rcptId,
130
+ action: 'badgr test',
131
+ deploymentId: depId,
132
+ gpu: dep.gpu_type,
133
+ status: 'test_complete',
134
+ createdAt: new Date().toISOString(),
135
+ });
136
+ step(chalk, true, 'Receipt created', rcptId);
137
+
138
+ // ── Summary ──────────────────────────────────────────────────────────────
139
+ console.log();
140
+ const passed = stopped;
141
+ if (passed) {
142
+ console.log(chalk.green(chalk.bold(' ✓ Test passed\n')));
143
+ } else {
144
+ console.log(chalk.red(chalk.bold(' ✗ Test failed\n')));
145
+ process.exit(1);
146
+ }
147
+ }
@@ -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 = [];