badgr-cli 1.0.20 → 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.20",
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,13 +24,21 @@ ${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')}
29
- ${chalk.dim('# Simplest Badgr picks the GPU:')}
31
+ ${chalk.dim('# Verify the stack works end-to-end:')}
32
+ badgr test
33
+
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
32
37
 
38
+ ${chalk.dim('# Opt into cheaper budget providers (Vast.ai etc.):')}
39
+ badgr run python train.py --cheap
40
+ badgr serve meta-llama/Llama-3.1-8B-Instruct --cheap
41
+
33
42
  ${chalk.dim('# Pin a specific GPU:')}
34
43
  badgr run python train.py --gpu A100
35
44
  badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S
@@ -44,10 +53,12 @@ ${chalk.bold('EXAMPLES')}
44
53
  badgr receipts dep-abc123
45
54
 
46
55
  ${chalk.bold('badgr run OPTIONS')}
47
- --gpu <type> GPU type (default: auto — Badgr picks cheapest available)
56
+ --gpu <type> GPU type (default: auto — Badgr picks best available on RunPod)
57
+ --cheap Search budget providers too (Vast.ai etc.) for lower prices
48
58
  --image <image> Docker image (default: python:3.11-slim)
49
59
  --count <n> Number of GPUs (default: 1)
50
60
  --region US|EU|AU Region preference
61
+ --tier 1|2 Provider tier: 1 = reliable (default), 2 = budget
51
62
  --max-price <$/hr> Hard spend cap per GPU-hour
52
63
  --max-runtime <min> Auto-stop after N minutes (recommended)
53
64
  --max-cost <$> Auto-stop when spend reaches this amount
@@ -55,8 +66,10 @@ ${chalk.bold('badgr run OPTIONS')}
55
66
 
56
67
  ${chalk.bold('badgr serve OPTIONS')}
57
68
  --gpu <type> GPU type (default: auto — inferred from model size)
69
+ --cheap Search budget providers too (Vast.ai etc.) for lower prices
58
70
  --count <n> Number of GPUs (default: 1)
59
71
  --region US|EU|AU Region preference
72
+ --tier 1|2 Provider tier: 1 = reliable (default), 2 = budget
60
73
  --max-price <$/hr> Hard spend cap per GPU-hour
61
74
  --no-wait Skip endpoint health check
62
75
 
@@ -90,6 +103,7 @@ async function main() {
90
103
  case 'receipts': return receiptsCommand(config, rest, chalk);
91
104
  case 'models': return modelsCommand(config, chalk);
92
105
  case 'capacity': return capacityCommand(config, rest, chalk);
106
+ case 'test': return testCommand(config, chalk);
93
107
  // legacy aliases kept for compatibility
94
108
  case 'up': return upCommand(config, rest, chalk);
95
109
  case 'config': {
@@ -19,6 +19,7 @@ export function parseRunArgs(args) {
19
19
  if (args[i] === '--count') { flags.count = parseInt(args[++i], 10); i++; continue; }
20
20
  if (args[i] === '--region') { flags.region = args[++i]; i++; continue; }
21
21
  if (args[i] === '--tier') { flags.tier = args[++i]; i++; continue; }
22
+ if (args[i] === '--cheap') { flags.cheap = true; i++; continue; }
22
23
  if (args[i] === '--max-price') { flags.maxPrice = parseFloat(args[++i]); i++; continue; }
23
24
  if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
24
25
  if (args[i] === '--detach') { flags.detach = true; i++; continue; }
@@ -269,11 +270,53 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
269
270
  }
270
271
  }
271
272
 
272
- // Ask the backend for the cheapest available GPU right now.
273
- // Returns { gpu, region, price } or null when nothing is available.
274
- async function findAutoGpu(config, chalk) {
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') {
275
318
  try {
276
- const params = new URLSearchParams({ max_price: '10' });
319
+ const params = new URLSearchParams({ max_price: '10', tier, workload });
277
320
  return await callApi(`/capacity/auto?${params}`, {
278
321
  apiKey: config.apiKey,
279
322
  baseUrl: config.baseUrl,
@@ -307,20 +350,31 @@ export async function runCommand(config, args, chalk) {
307
350
  const maxRuntimeMs = flags.maxRuntime ? flags.maxRuntime * 60 * 1000 : null;
308
351
  const maxCost = flags.maxCost ?? null;
309
352
 
353
+ // Resolve effective tier: --cheap and --tier 2 opt into budget providers;
354
+ // everything else defaults to tier 1 (RunPod only) for reliability.
355
+ const effectiveTier = (flags.cheap || flags.tier === '2' || flags.tier === 'tier2' || flags.tier === 'tier-2')
356
+ ? '2'
357
+ : (flags.tier || '1');
358
+
310
359
  // ── Auto GPU selection (no --gpu specified) ────────────────────────────────
311
360
  let gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : null;
312
361
  let autoRegion = null;
313
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
+
314
367
  if (!gpu) {
315
- console.log(chalk.bold('\n⚡ Running GPU job\n'));
368
+ console.log(chalk.bold(`\n⚡ Running ${workloadLabel}\n`));
316
369
  if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
317
370
  if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
371
+ if (effectiveTier === '2') console.log(` ${chalk.dim('(budget mode — searching all providers)')}`);
318
372
  console.log();
319
- process.stdout.write(chalk.dim(' Finding GPU...'));
373
+ process.stdout.write(chalk.dim(' Finding best GPU...'));
320
374
 
321
375
  let best;
322
376
  try {
323
- best = await findAutoGpu(config, chalk);
377
+ best = await findAutoGpu(config, chalk, effectiveTier, workload);
324
378
  } catch (err) {
325
379
  process.stdout.write('\n');
326
380
  console.error(chalk.red(`\n ✗ Could not find GPU capacity: ${err.message}\n`));
@@ -335,17 +389,17 @@ export async function runCommand(config, args, chalk) {
335
389
  process.exit(1);
336
390
  }
337
391
 
338
- 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}`);
339
394
  console.log();
340
395
 
341
- if (process.stdin.isTTY) {
342
- 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: `);
343
399
  if (answer.toLowerCase() === 'q') {
344
400
  console.log(chalk.dim('\n Cancelled.\n'));
345
401
  process.exit(0);
346
402
  }
347
- } else {
348
- console.log(chalk.dim(` Auto-selecting ${best.gpu} (non-interactive).`));
349
403
  }
350
404
 
351
405
  gpu = best.gpu;
@@ -385,7 +439,7 @@ export async function runCommand(config, args, chalk) {
385
439
  ...(effectiveRegion ? { region: effectiveRegion } : {}),
386
440
  max_price_per_hour: flags.maxPrice,
387
441
  name: flags.name,
388
- ...(flags.tier ? { tier: flags.tier } : {}),
442
+ tier: effectiveTier,
389
443
  };
390
444
  }
391
445
 
@@ -18,6 +18,7 @@ export function parseServeArgs(args) {
18
18
  if (args[i] === '--count') { flags.count = parseInt(args[++i], 10); i++; continue; }
19
19
  if (args[i] === '--region') { flags.region = args[++i]; i++; continue; }
20
20
  if (args[i] === '--tier') { flags.tier = args[++i]; i++; continue; }
21
+ if (args[i] === '--cheap') { flags.cheap = true; i++; continue; }
21
22
  if (args[i] === '--max-price') { flags.maxPrice = parseFloat(args[++i]); i++; continue; }
22
23
  if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
23
24
  if (args[i] === '--no-wait') { flags.noWait = true; i++; continue; }
@@ -27,21 +28,26 @@ export function parseServeArgs(args) {
27
28
  return { model, flags };
28
29
  }
29
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
+
30
37
  async function waitForEndpoint(endpointUrl, timeoutMs = 5 * 60 * 1000, chalk) {
31
- const deadline = Date.now() + timeoutMs;
32
- let attempt = 0;
38
+ const startMs = Date.now();
39
+ const deadline = startMs + timeoutMs;
33
40
 
34
41
  while (Date.now() < deadline) {
35
- attempt++;
36
42
  try {
37
43
  const res = await fetch(`${endpointUrl}/models`, { signal: AbortSignal.timeout(8000) });
38
- if (res.ok) return true;
44
+ if (res.ok) { process.stdout.write('\n'); return true; }
39
45
  } catch {
40
46
  // still starting
41
47
  }
42
- const elapsed = Math.round((Date.now() - (deadline - timeoutMs)) / 1000);
48
+ const elapsed = Math.round((Date.now() - startMs) / 1000);
43
49
  process.stdout.write(
44
- `\r ${chalk.dim(`Health-checking /v1/models… ${elapsed}s`)} `
50
+ `\r ${chalk.dim(_serveStageLabel(elapsed) + ` (${elapsed}s)`)} `
45
51
  );
46
52
  await new Promise(r => setTimeout(r, 8000));
47
53
  }
@@ -64,9 +70,15 @@ export async function serveCommand(config, args, chalk) {
64
70
  const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : 'AUTO';
65
71
  const gpuLabel = gpu === 'AUTO' ? 'auto' : gpu;
66
72
 
73
+ // Default to tier 1 (RunPod) for reliability; --cheap or --tier 2 opts into budget providers.
74
+ const effectiveTier = (flags.cheap || flags.tier === '2' || flags.tier === 'tier2' || flags.tier === 'tier-2')
75
+ ? '2'
76
+ : (flags.tier || '1');
77
+
67
78
  console.log(chalk.bold('\nServing model\n'));
68
79
  console.log(` ${chalk.bold('Model:')} ${model}`);
69
80
  console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
81
+ if (effectiveTier === '2') console.log(` ${chalk.dim('(budget mode — searching all providers)')}`);
70
82
  console.log();
71
83
  process.stdout.write(chalk.dim(' Finding GPU capacity...\n'));
72
84
 
@@ -80,7 +92,7 @@ export async function serveCommand(config, args, chalk) {
80
92
  ...(regionOverride || effectiveRegion ? { region: regionOverride || effectiveRegion } : {}),
81
93
  max_price_per_hour: flags.maxPrice,
82
94
  name: flags.name,
83
- ...(flags.tier ? { tier: flags.tier } : {}),
95
+ tier: effectiveTier,
84
96
  };
85
97
  }
86
98
 
@@ -205,12 +217,11 @@ export async function serveCommand(config, args, chalk) {
205
217
 
206
218
  // ── Result ────────────────────────────────────────────────────────────────
207
219
  if (endpointReady) {
208
- console.log(chalk.green('\n✓ Endpoint ready\n'));
220
+ console.log(chalk.green('\n ✓ Endpoint ready\n'));
209
221
  } else {
210
- console.log(chalk.yellow('\n⏳ Endpoint still starting\n'));
211
- console.log(chalk.dim(' The deployment is provisioned but not yet responding.'));
212
- console.log(chalk.dim(` Check: badgr logs ${dep.deployment_id}`));
213
- 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}`)}`);
214
225
  console.log();
215
226
  }
216
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', () => {
@@ -71,6 +72,21 @@ describe('parseRunArgs', () => {
71
72
  const { flags } = parseRunArgs(['python', 'train.py', '--max-cost', '5.00']);
72
73
  expect(flags.maxCost).toBe(5.0);
73
74
  });
75
+
76
+ it('parses --cheap flag', () => {
77
+ const { flags } = parseRunArgs(['python', 'train.py', '--cheap']);
78
+ expect(flags.cheap).toBe(true);
79
+ });
80
+
81
+ it('--cheap defaults to falsy when not passed', () => {
82
+ const { flags } = parseRunArgs(['python', 'train.py']);
83
+ expect(flags.cheap).toBeFalsy();
84
+ });
85
+
86
+ it('parses --tier 2 flag', () => {
87
+ const { flags } = parseRunArgs(['python', 'train.py', '--tier', '2']);
88
+ expect(flags.tier).toBe('2');
89
+ });
74
90
  });
75
91
 
76
92
  describe('classifyFailure', () => {
@@ -187,6 +203,55 @@ describe('parseServeArgs', () => {
187
203
  const { model } = parseServeArgs(['--gpu', 'RTX_4090']);
188
204
  expect(model).toBeNull();
189
205
  });
206
+
207
+ it('parses --cheap flag', () => {
208
+ const { flags } = parseServeArgs(['my/model', '--cheap']);
209
+ expect(flags.cheap).toBe(true);
210
+ });
211
+
212
+ it('--cheap defaults to falsy when not passed', () => {
213
+ const { flags } = parseServeArgs(['my/model']);
214
+ expect(flags.cheap).toBeFalsy();
215
+ });
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
+ });
190
255
  });
191
256
 
192
257
  describe('promptFallback output', () => {