badgr-cli 1.0.23 → 1.0.26

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.23",
3
+ "version": "1.0.26",
4
4
  "description": "Badgr, run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
package/src/badgr.js CHANGED
@@ -17,27 +17,27 @@ const HELP = `
17
17
  ${chalk.bold('badgr')} — run or serve GPU workloads from one command
18
18
 
19
19
  ${chalk.bold('COMMANDS')}
20
- ${chalk.cyan('badgr login')} Authenticate with your API key
21
- ${chalk.cyan('badgr run <command>')} Run a one-off GPU job
22
- ${chalk.cyan('badgr serve <model>')} Serve a model with an OpenAI-compatible endpoint
23
- ${chalk.cyan('badgr status')} Show what's running and what's billing
24
- ${chalk.cyan('badgr logs <id>')} Stream logs for a running job or endpoint
25
- ${chalk.cyan('badgr down <id>')} Stop a deployment and end billing
26
- ${chalk.cyan('badgr receipts')} Show cost history
27
- ${chalk.cyan('badgr test')} Run an end-to-end test (provision → run → teardown)
28
- ${chalk.cyan('badgr capacity')} Check what GPU capacity is available right now
20
+ ${chalk.cyan('badgr login')} Authenticate with your API key
21
+ ${chalk.cyan('badgr run <command>')} Run a one-off GPU job
22
+ ${chalk.cyan('badgr serve <model>')} Serve a model with an OpenAI-compatible endpoint
23
+ ${chalk.cyan('badgr status')} Show what's running and what's billing
24
+ ${chalk.cyan('badgr logs <id>')} Stream logs for a running job or endpoint
25
+ ${chalk.cyan('badgr down <id>')} Stop a deployment and end billing
26
+ ${chalk.cyan('badgr receipts')} Show cost history
27
+ ${chalk.cyan('badgr test')} Run an end-to-end test (provision → run → teardown)
28
+ ${chalk.cyan('badgr capacity')} Check what GPU capacity is available right now
29
29
 
30
30
  ${chalk.bold('EXAMPLES')}
31
31
  ${chalk.dim('# Verify the stack works end-to-end:')}
32
32
  badgr test
33
33
 
34
- ${chalk.dim('# SimplestBadgr picks the GPU (RunPod, reliable):')}
34
+ ${chalk.dim('# Tier 1 managed provider routing (default):')}
35
35
  badgr run python train.py
36
36
  badgr serve meta-llama/Llama-3.1-8B-Instruct
37
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
38
+ ${chalk.dim('# Tier 2 marketplace routing, lower-cost options:')}
39
+ badgr run python train.py --tier 2
40
+ badgr serve meta-llama/Llama-3.1-8B-Instruct --tier 2
41
41
 
42
42
  ${chalk.dim('# Pin a specific GPU:')}
43
43
  badgr run python train.py --gpu A100
@@ -53,12 +53,12 @@ ${chalk.bold('EXAMPLES')}
53
53
  badgr receipts dep-abc123
54
54
 
55
55
  ${chalk.bold('badgr run OPTIONS')}
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
56
+ --gpu <type> GPU type (default: auto — Badgr picks best available)
57
+ --tier 1 Managed provider routing (default)
58
+ --tier 2 Marketplace provider routing, lower-cost options
58
59
  --image <image> Docker image (default: python:3.11-slim)
59
60
  --count <n> Number of GPUs (default: 1)
60
61
  --region US|EU|AU Region preference
61
- --tier 1|2 Provider tier: 1 = reliable (default), 2 = budget
62
62
  --max-price <$/hr> Hard spend cap per GPU-hour
63
63
  --max-runtime <min> Auto-stop after N minutes (recommended)
64
64
  --max-cost <$> Auto-stop when spend reaches this amount
@@ -66,10 +66,10 @@ ${chalk.bold('badgr run OPTIONS')}
66
66
 
67
67
  ${chalk.bold('badgr serve OPTIONS')}
68
68
  --gpu <type> GPU type (default: auto — inferred from model size)
69
- --cheap Search budget providers too (Vast.ai etc.) for lower prices
69
+ --tier 1 Managed provider routing (default)
70
+ --tier 2 Marketplace provider routing, lower-cost options
70
71
  --count <n> Number of GPUs (default: 1)
71
72
  --region US|EU|AU Region preference
72
- --tier 1|2 Provider tier: 1 = reliable (default), 2 = budget
73
73
  --max-price <$/hr> Hard spend cap per GPU-hour
74
74
  --no-wait Skip endpoint health check
75
75
 
@@ -103,7 +103,7 @@ async function main() {
103
103
  case 'receipts': return receiptsCommand(config, rest, chalk);
104
104
  case 'models': return modelsCommand(config, chalk);
105
105
  case 'capacity': return capacityCommand(config, rest, chalk);
106
- case 'test': return testCommand(config, chalk);
106
+ case 'test': return testCommand(config, rest, chalk);
107
107
  // legacy aliases kept for compatibility
108
108
  case 'up': return upCommand(config, rest, chalk);
109
109
  case 'config': {
@@ -18,7 +18,6 @@ export function parseRunArgs(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; }
22
21
  if (args[i] === '--max-price') { flags.maxPrice = parseFloat(args[++i]); i++; continue; }
23
22
  if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
24
23
  if (args[i] === '--detach') { flags.detach = true; i++; continue; }
@@ -82,18 +81,36 @@ function renderStatusBar(chalk, { elapsedMs, ratePerHour, gpuUtil, cpuUtil, maxR
82
81
  return chalk.dim(' ' + parts.join(' • '));
83
82
  }
84
83
 
85
- // Wait for status to leave 'starting' (smoke check running on backend).
84
+ // Wait for status to leave 'starting'/'queued'/'provisioning'.
85
+ // This covers image pull time — runtime limit does NOT start until this returns.
86
86
  // Returns the final dep object with status 'running' or 'failed'.
87
87
  async function waitForRunning(config, depId, chalk) {
88
- const POLL_MS = 3000;
89
- const TIMEOUT_MS = 120_000; // 2 min max for smoke check
90
- const startMs = Date.now();
91
- let dots = 0;
88
+ const POLL_MS = 3000;
89
+ const TIMEOUT_MS = 5 * 60 * 1000; // 5 min startup grace (image pull + container init)
90
+ const startMs = Date.now();
91
+ const PHASES = [
92
+ { afterMs: 0, label: ' Starting machine' },
93
+ { afterMs: 15000, label: ' Pulling image' },
94
+ { afterMs: 60000, label: ' Starting container' },
95
+ { afterMs: 180000, label: ' Running command' },
96
+ ];
97
+
98
+ let lastPhaseIdx = -1;
99
+ const STARTUP_STATES = new Set(['queued', 'provisioning', 'starting']);
92
100
 
93
- process.stdout.write(chalk.dim(' Waiting for container to start'));
94
101
  const ticker = setInterval(() => {
102
+ const elapsed = Date.now() - startMs;
103
+ // Find the latest phase whose afterMs has been passed
104
+ let phaseIdx = 0;
105
+ for (let i = 0; i < PHASES.length; i++) {
106
+ if (elapsed >= PHASES[i].afterMs) phaseIdx = i;
107
+ }
108
+ if (phaseIdx !== lastPhaseIdx) {
109
+ process.stdout.write('\r\x1b[2K');
110
+ process.stdout.write(chalk.dim(PHASES[phaseIdx].label));
111
+ lastPhaseIdx = phaseIdx;
112
+ }
95
113
  process.stdout.write('.');
96
- dots++;
97
114
  }, 1000);
98
115
 
99
116
  try {
@@ -103,23 +120,22 @@ async function waitForRunning(config, depId, chalk) {
103
120
  apiKey: config.apiKey,
104
121
  baseUrl: config.baseUrl,
105
122
  });
106
- if (dep.status !== 'starting') {
107
- clearInterval(ticker);
123
+ if (!STARTUP_STATES.has(dep.status)) {
108
124
  process.stdout.write('\n');
109
125
  return dep;
110
126
  }
111
127
  }
112
128
  } finally {
113
129
  clearInterval(ticker);
114
- if (dots > 0) process.stdout.write('\n');
130
+ process.stdout.write('\n');
115
131
  }
116
132
 
117
- // Timed out waiting — return whatever we have
133
+ // Timed out — return last known state
118
134
  return await callApi(`/deployments/${depId}`, { apiKey: config.apiKey, baseUrl: config.baseUrl });
119
135
  }
120
136
 
121
137
  async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost = null, ratePerHour = 0, onTeardown }) {
122
- const TERMINAL = new Set(['stopped', 'failed', 'completed']);
138
+ const TERMINAL = new Set(['stopped', 'failed', 'completed', 'succeeded']);
123
139
  const POLL_MS = 4000;
124
140
  let seenContent = new Set(); // track by content, not index, to avoid reprinting stale lines
125
141
  let lastStatus = '';
@@ -343,66 +359,33 @@ export async function runCommand(config, args, chalk) {
343
359
  requireApiKey(config);
344
360
 
345
361
  const command = positional.length > 0 ? positional : undefined;
346
- const image = flags.image || (command ? 'python:3.11-slim' : undefined);
362
+ // Use alpine for smoke tests (7MB vs 50MB — much faster pull), slim for general workloads
363
+ const inferredImage = (command && inferWorkload(command) === 'smoke_test')
364
+ ? 'python:3.11-alpine'
365
+ : 'python:3.11-slim';
366
+ const image = flags.image || (command ? inferredImage : undefined);
347
367
  const detach = flags.detach || false;
348
368
  const fallbackMode = flags.noFallback ? 'none' : (flags.fallback || 'closest');
349
369
  const maxRuntimeMs = flags.maxRuntime ? flags.maxRuntime * 60 * 1000 : null;
350
370
  const maxCost = flags.maxCost ?? null;
351
371
 
352
- // Resolve effective tier: --cheap and --tier 2 opt into budget providers;
353
- // everything else defaults to tier 1 (RunPod only) for reliability.
354
- const effectiveTier = (flags.cheap || flags.tier === '2' || flags.tier === 'tier2' || flags.tier === 'tier-2')
372
+ // Tier 1 = managed routing (default). Tier 2 = marketplace routing, opt-in via --tier 2.
373
+ const effectiveTier = (flags.tier === '2' || flags.tier === 'tier2' || flags.tier === 'tier-2')
355
374
  ? '2'
356
375
  : (flags.tier || '1');
357
376
 
358
377
  // ── Auto GPU selection (no --gpu specified) ────────────────────────────────
359
- let gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : null;
360
- let autoRegion = null;
378
+ let gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : 'auto';
361
379
 
362
380
  // Infer workload from the command so the backend can apply the correct VRAM floor.
363
381
  const workload = command ? inferWorkload(command) : 'general';
364
382
  const workloadLabel = WORKLOAD_LABELS[workload] || 'GPU job';
365
383
 
366
- if (!gpu) {
384
+ if (gpu === 'auto') {
367
385
  console.log(chalk.bold(`\n⚡ Running ${workloadLabel}\n`));
368
386
  if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
369
387
  if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
370
- if (effectiveTier === '2') console.log(` ${chalk.dim('(budget modesearching all providers)')}`);
371
- console.log();
372
- process.stdout.write(chalk.dim(' Finding best GPU...'));
373
-
374
- let best;
375
- try {
376
- best = await findAutoGpu(config, chalk, effectiveTier, workload);
377
- } catch (err) {
378
- process.stdout.write('\n');
379
- console.error(chalk.red(`\n ✗ Could not find GPU capacity: ${err.message}\n`));
380
- process.exit(1);
381
- }
382
-
383
- process.stdout.write('\n');
384
-
385
- if (!best) {
386
- console.error(chalk.red('\n ✗ No GPU capacity available right now.\n'));
387
- console.error(chalk.dim(' Run `badgr capacity` for details, or try again in a few minutes.'));
388
- process.exit(1);
389
- }
390
-
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}`);
393
- console.log();
394
-
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: `);
398
- if (answer.toLowerCase() === 'q') {
399
- console.log(chalk.dim('\n Cancelled.\n'));
400
- process.exit(0);
401
- }
402
- }
403
-
404
- gpu = best.gpu;
405
- autoRegion = best.region;
388
+ if (effectiveTier === '2') console.log(` ${chalk.dim('(tier 2marketplace routing)')}`)
406
389
  console.log();
407
390
  } else {
408
391
  // Specific GPU requested — show header
@@ -421,15 +404,13 @@ export async function runCommand(config, args, chalk) {
421
404
  }
422
405
  }
423
406
 
424
- console.log(chalk.dim(' Finding best available GPU capacity...'));
407
+ console.log(chalk.dim(' Starting machine...'));
425
408
  if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
426
409
  console.log(chalk.dim(` API: ${config.baseUrl}`));
427
410
  }
428
411
 
429
- function buildBody(gpuOverride, regionOverride) {
430
- const effectiveRegion = regionOverride
431
- ?? autoRegion
432
- ?? (flags.region ? flags.region.toUpperCase() : undefined);
412
+ function buildBody(gpuOverride) {
413
+ const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
433
414
  return {
434
415
  command,
435
416
  image,
@@ -459,20 +440,20 @@ export async function runCommand(config, args, chalk) {
459
440
  process.exit(1);
460
441
  }
461
442
 
462
- // Tier 1 (RunPod) is out of capacity — offer budget providers.
443
+ // Tier 1 out of capacity — offer tier 2 marketplace routing.
463
444
  if (process.stdin.isTTY) {
464
445
  const answer = await askConfirm(
465
- `\n RunPod has no suitable capacity. Try budget marketplace GPUs? (Vast.ai, Salad) [${chalk.bold('Enter')}/${chalk.bold('q')}]: `
446
+ `\n No tier 1 capacity available. Try tier 2 marketplace routing? [${chalk.bold('Enter')}/${chalk.bold('q')}]: `
466
447
  );
467
448
  if (answer.toLowerCase() === 'q') {
468
449
  console.log(chalk.dim('\n Cancelled.\n'));
469
450
  process.exit(0);
470
451
  }
471
452
  } else {
472
- console.log(chalk.dim('\n RunPod capacity unavailable falling back to budget providers...\n'));
453
+ console.log(chalk.dim('\n No tier 1 capacity — trying tier 2 marketplace routing...\n'));
473
454
  }
474
455
 
475
- console.log(chalk.dim(' Searching budget providers (Vast.ai, Salad)...'));
456
+ console.log(chalk.dim(' Searching tier 2 capacity...'));
476
457
  try {
477
458
  dep = await callApi('/run', {
478
459
  method: 'POST',
@@ -487,13 +468,14 @@ export async function runCommand(config, args, chalk) {
487
468
  console.error(chalk.dim(' Try `badgr capacity` to see what\'s available, or try again shortly.'));
488
469
  } else if (d2?.code === 'PROVISIONING_FAILED' || d2?.code === 'PROVIDER_ADAPTER_ERROR') {
489
470
  console.error(chalk.red(`\n ✗ Budget provider found capacity but could not start the machine. Please try again.\n`));
471
+ // (error detail kept below)
490
472
  if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
491
473
  if (d2?.debug_error) console.error(chalk.dim(` Provider detail: ${d2.debug_error}`));
492
474
  } else {
493
475
  console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.\n`));
494
476
  }
495
477
  } else {
496
- console.error(chalk.red(`\n ✗ Could not start job on budget providers: ${err2.message}\n`));
478
+ console.error(chalk.red(`\n ✗ Could not start job on tier 2: ${err2.message}\n`));
497
479
  console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.`));
498
480
  }
499
481
  process.exit(1);
@@ -545,8 +527,10 @@ export async function runCommand(config, args, chalk) {
545
527
  return;
546
528
  }
547
529
 
548
- // If the backend is still running the smoke check, wait for it to finish.
549
- if (dep.status === 'starting') {
530
+ // Wait through startup phases (queued provisioning starting running).
531
+ // The max-runtime clock does NOT start until this returns — image pull time is free.
532
+ const STARTUP_STATES = new Set(['queued', 'provisioning', 'starting']);
533
+ if (STARTUP_STATES.has(dep.status)) {
550
534
  console.log();
551
535
  dep = await waitForRunning(config, dep.deployment_id, chalk);
552
536
  }
@@ -560,7 +544,7 @@ export async function runCommand(config, args, chalk) {
560
544
  }
561
545
 
562
546
  const ratePerHour = dep.cost_per_hour || 0;
563
- console.log(chalk.dim('\n ── Live status (Ctrl+C to stop) ─────────────────────────────────\n'));
547
+ console.log(chalk.dim('\n ── Running command (Ctrl+C to stop) ────────────────────────────\n'));
564
548
 
565
549
  async function teardown(reason) {
566
550
  const labels = {
@@ -622,7 +606,7 @@ export async function runCommand(config, args, chalk) {
622
606
  }
623
607
  console.log();
624
608
  process.exit(exitCode ?? 1);
625
- } else if (finalStatus === 'completed' && (exitCode === 0 || exitCode === null)) {
626
- console.log(chalk.green(`\n ✓ Complete\n`));
609
+ } else if ((finalStatus === 'completed' || finalStatus === 'succeeded') && (exitCode === 0 || exitCode === null)) {
610
+ console.log(chalk.green(`\n ✓ Job complete\n`));
627
611
  }
628
612
  }
@@ -23,7 +23,6 @@ export function parseServeArgs(args) {
23
23
  if (args[i] === '--count') { flags.count = parseInt(args[++i], 10); i++; continue; }
24
24
  if (args[i] === '--region') { flags.region = args[++i]; i++; continue; }
25
25
  if (args[i] === '--tier') { flags.tier = args[++i]; i++; continue; }
26
- if (args[i] === '--cheap') { flags.cheap = true; i++; continue; }
27
26
  if (args[i] === '--max-price') { flags.maxPrice = parseFloat(args[++i]); i++; continue; }
28
27
  if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
29
28
  if (args[i] === '--no-wait') { flags.noWait = true; i++; continue; }
@@ -75,15 +74,15 @@ export async function serveCommand(config, args, chalk) {
75
74
  const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : 'AUTO';
76
75
  const gpuLabel = gpu === 'AUTO' ? 'auto' : gpu;
77
76
 
78
- // Default to tier 1 (RunPod) for reliability; --cheap or --tier 2 opts into budget providers.
79
- const effectiveTier = (flags.cheap || flags.tier === '2' || flags.tier === 'tier2' || flags.tier === 'tier-2')
77
+ // Tier 1 = managed routing (default). Tier 2 = marketplace routing, opt-in via --tier 2.
78
+ const effectiveTier = (flags.tier === '2' || flags.tier === 'tier2' || flags.tier === 'tier-2')
80
79
  ? '2'
81
80
  : (flags.tier || '1');
82
81
 
83
82
  console.log(chalk.bold('\nServing model\n'));
84
83
  console.log(` ${chalk.bold('Model:')} ${model}`);
85
84
  console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
86
- if (effectiveTier === '2') console.log(` ${chalk.dim('(budget modesearching all providers)')}`);
85
+ if (effectiveTier === '2') console.log(` ${chalk.dim('(tier 2marketplace routing)')}`);
87
86
  console.log();
88
87
  process.stdout.write(chalk.dim(' Finding GPU capacity...\n'));
89
88
 
@@ -118,20 +117,20 @@ export async function serveCommand(config, args, chalk) {
118
117
  process.exit(1);
119
118
  }
120
119
 
121
- // Tier 1 (RunPod) out of capacity — offer budget providers.
120
+ // Tier 1 out of capacity — offer tier 2 marketplace routing.
122
121
  if (process.stdin.isTTY) {
123
122
  const answer = await askConfirm(
124
- `\n RunPod has no suitable capacity. Try budget marketplace GPUs? (Vast.ai, Salad) [${chalk.bold('Enter')}/${chalk.bold('q')}]: `
123
+ `\n No tier 1 capacity available. Try tier 2 marketplace routing? [${chalk.bold('Enter')}/${chalk.bold('q')}]: `
125
124
  );
126
125
  if (answer.toLowerCase() === 'q') {
127
126
  console.log(chalk.dim('\n Cancelled.\n'));
128
127
  process.exit(0);
129
128
  }
130
129
  } else {
131
- console.log(chalk.dim('\n RunPod capacity unavailable falling back to budget providers...\n'));
130
+ console.log(chalk.dim('\n No tier 1 capacity — trying tier 2 marketplace routing...\n'));
132
131
  }
133
132
 
134
- console.log(chalk.dim(' Searching budget providers (Vast.ai, Salad)...'));
133
+ console.log(chalk.dim(' Searching tier 2 capacity...'));
135
134
  try {
136
135
  dep = await callApi('/serve', {
137
136
  method: 'POST',
@@ -148,7 +147,7 @@ export async function serveCommand(config, args, chalk) {
148
147
  console.error(chalk.red(`\n ✗ Budget provider found capacity but could not start the endpoint. Please try again.\n`));
149
148
  console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
150
149
  } else {
151
- console.error(chalk.red(`\n ✗ Failed to start endpoint on budget providers: ${err2.message}\n`));
150
+ console.error(chalk.red(`\n ✗ Could not start endpoint on tier 2: ${err2.message}\n`));
152
151
  console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
153
152
  }
154
153
  process.exit(1);
@@ -6,9 +6,25 @@ import { addReceipt, generateReceiptId } from '../store.js';
6
6
  const TEST_MAX_PRICE = 1.50;
7
7
  const TEST_MAX_RUNTIME_MS = 2 * 60 * 1000;
8
8
  const TEST_COMMAND = ['python', '-c', "print('hello from badgr')"];
9
- const TEST_IMAGE = 'python:3.11-slim';
9
+ // Use alpine (7MB) instead of slim (50MB) — dramatically faster image pull for smoke tests.
10
+ // Falls back gracefully: alpine has python3 and supports the test command identically.
11
+ const TEST_IMAGE = 'python:3.11-alpine';
10
12
  const EXPECTED_OUTPUT = 'hello from badgr';
11
13
 
14
+ // --provider flag resolves to a backend tier value.
15
+ // 'tier1' → managed routing (default), 'tier2' → marketplace routing, 'secondary' → secondary dispatch.
16
+ const PROVIDER_TO_TIER = { tier1: '1', tier2: '2', secondary: 'modal' };
17
+
18
+ export function parseTestArgs(args) {
19
+ const flags = {};
20
+ let i = 0;
21
+ while (i < args.length) {
22
+ if (args[i] === '--provider' && args[i + 1]) { flags.provider = args[++i]; i++; continue; }
23
+ i++;
24
+ }
25
+ return flags;
26
+ }
27
+
12
28
  function step(chalk, ok, msg, detail = '') {
13
29
  const icon = ok ? chalk.green('✓') : chalk.red('✗');
14
30
  const suffix = detail ? chalk.dim(` — ${detail}`) : '';
@@ -46,12 +62,40 @@ async function pollLogs(config, depId, expected, timeoutMs) {
46
62
  return false;
47
63
  }
48
64
 
49
- export async function testCommand(config, chalk) {
65
+ export async function testCommand(config, args, chalk) {
50
66
  requireApiKey(config);
51
67
 
68
+ const flags = parseTestArgs(Array.isArray(args) ? args : []);
69
+ const providerKey = flags.provider ? flags.provider.toLowerCase() : 'tier1';
70
+
71
+ if (providerKey === 'secondary') {
72
+ // Secondary dispatch provider uses a webhook model, not direct GPU rental.
73
+ // Verify the backend reports it as configured.
74
+ console.log(chalk.bold('\n⚡ Testing secondary dispatch provider\n'));
75
+ let routes;
76
+ try {
77
+ routes = await callApi('/compute/routes', { apiKey: config.apiKey, baseUrl: config.baseUrl });
78
+ } catch {
79
+ routes = null;
80
+ }
81
+ const secondaryRoute = Array.isArray(routes) ? routes.find(r => r.name === 'modal') : null;
82
+ if (secondaryRoute?.available) {
83
+ step(chalk, true, 'Secondary provider configured');
84
+ console.log(chalk.green('\n ✓ Secondary dispatch provider is ready\n'));
85
+ } else {
86
+ step(chalk, false, 'Secondary provider configured', 'contact support to enable secondary dispatch');
87
+ console.log(chalk.red('\n ✗ Secondary dispatch provider is not configured\n'));
88
+ process.exit(1);
89
+ }
90
+ return;
91
+ }
92
+
93
+ const tier = PROVIDER_TO_TIER[providerKey] ?? '1';
94
+ const tierLabel = tier === '1' ? 'tier 1 (managed routing)' : 'tier 2 (marketplace routing)';
95
+
52
96
  console.log(chalk.bold('\n⚡ Running end-to-end test\n'));
53
97
  console.log(chalk.dim(` Command: ${TEST_COMMAND.join(' ')}`));
54
- console.log(chalk.dim(` Provider: RunPod (Tier 1 — reliable)`));
98
+ console.log(chalk.dim(` Routing: ${tier === '1' ? 'tier 1 — managed provider routing' : 'tier 2 — marketplace routing'}`));
55
99
  console.log(chalk.dim(` Budget: max $${TEST_MAX_PRICE.toFixed(2)}/hr · 2 minute cap (~$0.05 max)`));
56
100
  console.log();
57
101
 
@@ -59,12 +103,12 @@ export async function testCommand(config, chalk) {
59
103
  let depId;
60
104
 
61
105
  // ── 1. Provision ─────────────────────────────────────────────────────────
62
- process.stdout.write(chalk.dim(' Provisioning GPU (RunPod)...'));
106
+ process.stdout.write(chalk.dim(` Provisioning GPU (${tierLabel})...\n`));
63
107
  let dep;
64
108
  const baseBody = {
65
109
  command: TEST_COMMAND,
66
110
  image: TEST_IMAGE,
67
- gpu: 'RTX_3080',
111
+ gpu: 'auto',
68
112
  max_price_per_hour: TEST_MAX_PRICE,
69
113
  };
70
114
  try {
@@ -72,12 +116,12 @@ export async function testCommand(config, chalk) {
72
116
  method: 'POST',
73
117
  apiKey: config.apiKey,
74
118
  baseUrl: config.baseUrl,
75
- body: { ...baseBody, tier: '1' },
119
+ body: { ...baseBody, tier },
76
120
  });
77
121
  } catch (err) {
78
- if (err.errorData?.code === 'NO_CAPACITY_MATCH') {
122
+ if (err.errorData?.code === 'NO_CAPACITY_MATCH' && tier === '1') {
79
123
  process.stdout.write('\n');
80
- process.stdout.write(chalk.dim(' RunPod unavailable, trying budget providers...'));
124
+ process.stdout.write(chalk.dim(' No tier 1 capacity — trying tier 2 marketplace routing...'));
81
125
  try {
82
126
  dep = await callApi('/run', {
83
127
  method: 'POST',
@@ -148,18 +192,22 @@ export async function testCommand(config, chalk) {
148
192
  action: 'badgr test',
149
193
  deploymentId: depId,
150
194
  gpu: dep.gpu_type,
151
- status: 'test_complete',
195
+ status: gotOutput ? 'test_passed' : 'test_failed',
152
196
  createdAt: new Date().toISOString(),
153
197
  });
154
198
  step(chalk, true, 'Receipt created', rcptId);
155
199
 
156
200
  // ── Summary ──────────────────────────────────────────────────────────────
157
201
  console.log();
158
- const passed = stopped;
202
+ const passed = stopped && gotOutput;
159
203
  if (passed) {
160
204
  console.log(chalk.green(chalk.bold(' ✓ Test passed\n')));
161
205
  } else {
162
- console.log(chalk.red(chalk.bold(' ✗ Test failed\n')));
206
+ if (!gotOutput) {
207
+ console.error(chalk.red(' Test failed — expected output not found in logs\n'));
208
+ } else {
209
+ console.error(chalk.red(' Test failed — could not stop billing\n'));
210
+ }
163
211
  process.exit(1);
164
212
  }
165
213
  }
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
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
+ import { testCommand, parseTestArgs } from '../src/commands/test-run.js';
5
5
  import { rankAlternatives, diffDescription, promptFallback } from '../src/fallback.js';
6
6
 
7
7
  describe('parseRunArgs', () => {
@@ -73,20 +73,15 @@ describe('parseRunArgs', () => {
73
73
  expect(flags.maxCost).toBe(5.0);
74
74
  });
75
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
76
  it('parses --tier 2 flag', () => {
87
77
  const { flags } = parseRunArgs(['python', 'train.py', '--tier', '2']);
88
78
  expect(flags.tier).toBe('2');
89
79
  });
80
+
81
+ it('--tier defaults to undefined when not passed', () => {
82
+ const { flags } = parseRunArgs(['python', 'train.py']);
83
+ expect(flags.tier).toBeUndefined();
84
+ });
90
85
  });
91
86
 
92
87
  describe('classifyFailure', () => {
@@ -204,14 +199,14 @@ describe('parseServeArgs', () => {
204
199
  expect(model).toBeNull();
205
200
  });
206
201
 
207
- it('parses --cheap flag', () => {
208
- const { flags } = parseServeArgs(['my/model', '--cheap']);
209
- expect(flags.cheap).toBe(true);
202
+ it('parses --tier 2 flag', () => {
203
+ const { flags } = parseServeArgs(['my/model', '--tier', '2']);
204
+ expect(flags.tier).toBe('2');
210
205
  });
211
206
 
212
- it('--cheap defaults to falsy when not passed', () => {
207
+ it('--tier defaults to undefined when not passed', () => {
213
208
  const { flags } = parseServeArgs(['my/model']);
214
- expect(flags.cheap).toBeFalsy();
209
+ expect(flags.tier).toBeUndefined();
215
210
  });
216
211
  });
217
212
 
@@ -221,6 +216,24 @@ describe('testCommand', () => {
221
216
  });
222
217
  });
223
218
 
219
+ describe('parseTestArgs', () => {
220
+ it('returns empty flags for no args', () => {
221
+ expect(parseTestArgs([])).toEqual({});
222
+ });
223
+
224
+ it('parses --provider tier1', () => {
225
+ expect(parseTestArgs(['--provider', 'tier1'])).toEqual({ provider: 'tier1' });
226
+ });
227
+
228
+ it('parses --provider tier2', () => {
229
+ expect(parseTestArgs(['--provider', 'tier2'])).toEqual({ provider: 'tier2' });
230
+ });
231
+
232
+ it('parses --provider secondary', () => {
233
+ expect(parseTestArgs(['--provider', 'secondary'])).toEqual({ provider: 'secondary' });
234
+ });
235
+ });
236
+
224
237
  describe('inferWorkload', () => {
225
238
  it('returns general for empty command', () => {
226
239
  expect(inferWorkload([])).toBe('general');