badgr-cli 1.0.22 → 1.0.25

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.22",
3
+ "version": "1.0.25",
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': {
@@ -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
@@ -19,7 +18,6 @@ export function parseRunArgs(args) {
19
18
  if (args[i] === '--count') { flags.count = parseInt(args[++i], 10); i++; continue; }
20
19
  if (args[i] === '--region') { flags.region = args[++i]; i++; continue; }
21
20
  if (args[i] === '--tier') { flags.tier = args[++i]; i++; continue; }
22
- if (args[i] === '--cheap') { flags.cheap = true; i++; continue; }
23
21
  if (args[i] === '--max-price') { flags.maxPrice = parseFloat(args[++i]); i++; continue; }
24
22
  if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
25
23
  if (args[i] === '--detach') { flags.detach = true; i++; continue; }
@@ -350,9 +348,8 @@ export async function runCommand(config, args, chalk) {
350
348
  const maxRuntimeMs = flags.maxRuntime ? flags.maxRuntime * 60 * 1000 : null;
351
349
  const maxCost = flags.maxCost ?? null;
352
350
 
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')
351
+ // Tier 1 = managed routing (default). Tier 2 = marketplace routing, opt-in via --tier 2.
352
+ const effectiveTier = (flags.tier === '2' || flags.tier === 'tier2' || flags.tier === 'tier-2')
356
353
  ? '2'
357
354
  : (flags.tier || '1');
358
355
 
@@ -368,7 +365,7 @@ export async function runCommand(config, args, chalk) {
368
365
  console.log(chalk.bold(`\n⚡ Running ${workloadLabel}\n`));
369
366
  if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
370
367
  if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
371
- if (effectiveTier === '2') console.log(` ${chalk.dim('(budget modesearching all providers)')}`);
368
+ if (effectiveTier === '2') console.log(` ${chalk.dim('(tier 2marketplace routing)')}`)
372
369
  console.log();
373
370
  process.stdout.write(chalk.dim(' Finding best GPU...'));
374
371
 
@@ -395,7 +392,7 @@ export async function runCommand(config, args, chalk) {
395
392
 
396
393
  if (effectiveTier === '2' && process.stdin.isTTY) {
397
394
  // 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: `);
395
+ const answer = await askConfirm(` Press ${chalk.bold('Enter')} to run on tier 2 (marketplace), or ${chalk.bold('q')} to cancel: `);
399
396
  if (answer.toLowerCase() === 'q') {
400
397
  console.log(chalk.dim('\n Cancelled.\n'));
401
398
  process.exit(0);
@@ -454,56 +451,49 @@ export async function runCommand(config, args, chalk) {
454
451
  } catch (err) {
455
452
  const d = err.errorData;
456
453
  if (d?.code === 'NO_CAPACITY_MATCH') {
457
- if (fallbackMode === 'none') {
458
- console.error(chalk.red(`\n ✗ ${gpu} isn't available right now.\n`));
459
- console.error(chalk.dim(' Pass --fallback closest to auto-select an alternative.'));
460
- process.exit(1);
461
- }
462
-
463
- const pool = [
464
- ...(Array.isArray(d.same_gpu_other_regions) ? d.same_gpu_other_regions : []),
465
- ...(Array.isArray(d.alternatives) ? d.alternatives : []),
466
- ];
467
-
468
- if (pool.length === 0) {
469
- console.error(chalk.red(`\n ✗ ${gpu} isn't available right now and no alternatives were found.\n`));
454
+ if (effectiveTier === '2') {
455
+ console.error(chalk.red('\n ✗ No GPU capacity available right now on any provider.\n'));
470
456
  console.error(chalk.dim(' Try `badgr capacity` to see what\'s available, or try again shortly.'));
471
457
  process.exit(1);
472
458
  }
473
459
 
474
- const ranked = rankAlternatives(gpu, pool, fallbackMode);
475
- const chosen = await promptFallback(gpu, ranked, chalk);
476
-
477
- if (!chosen) {
478
- console.log(chalk.dim('\n Cancelled.\n'));
479
- process.exit(0);
460
+ // Tier 1 out of capacity — offer tier 2 marketplace routing.
461
+ if (process.stdin.isTTY) {
462
+ const answer = await askConfirm(
463
+ `\n No tier 1 capacity available. Try tier 2 marketplace routing? [${chalk.bold('Enter')}/${chalk.bold('q')}]: `
464
+ );
465
+ if (answer.toLowerCase() === 'q') {
466
+ console.log(chalk.dim('\n Cancelled.\n'));
467
+ process.exit(0);
468
+ }
469
+ } else {
470
+ console.log(chalk.dim('\n No tier 1 capacity — trying tier 2 marketplace routing...\n'));
480
471
  }
481
472
 
482
- console.log(chalk.dim(`\n Trying ${chosen.gpu} in ${chosen.region}...\n`));
483
-
473
+ console.log(chalk.dim(' Searching tier 2 capacity...'));
484
474
  try {
485
475
  dep = await callApi('/run', {
486
476
  method: 'POST',
487
477
  apiKey: config.apiKey,
488
478
  baseUrl: config.baseUrl,
489
- body: buildBody(chosen.gpu, chosen.region),
479
+ body: { ...buildBody(), tier: '2' },
490
480
  });
491
481
  } catch (err2) {
492
482
  const d2 = err2.errorData;
493
- if (d2?.code === 'PROVISIONING_FAILED' || d2?.code === 'PROVIDER_ADAPTER_ERROR') {
494
- if (d2?.low_cost_provider_failed) {
495
- console.error(chalk.red(`\n Tier 2 unavailable. Tier 1 also unavailable. Try again shortly.\n`));
496
- } else {
497
- console.error(chalk.red(`\n ✗ Badgr found ${chosen.gpu} capacity but could not start the machine. Please try again.\n`));
498
- }
483
+ if (d2?.code === 'NO_CAPACITY_MATCH') {
484
+ console.error(chalk.red('\n ✗ No GPU capacity available right now on any provider.\n'));
485
+ console.error(chalk.dim(' Try `badgr capacity` to see what\'s available, or try again shortly.'));
486
+ } else if (d2?.code === 'PROVISIONING_FAILED' || d2?.code === 'PROVIDER_ADAPTER_ERROR') {
487
+ console.error(chalk.red(`\n ✗ Budget provider found capacity but could not start the machine. Please try again.\n`));
488
+ // (error detail kept below)
499
489
  if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
500
490
  if (d2?.debug_error) console.error(chalk.dim(` Provider detail: ${d2.debug_error}`));
491
+ } else {
492
+ console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.\n`));
501
493
  }
502
494
  } else {
503
- console.error(chalk.red(`\n ✗ Job failed to start on ${chosen.gpu}: ${err2.message}\n`));
504
- }
505
- if (!(process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true')) {
506
- console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.\n`));
495
+ console.error(chalk.red(`\n ✗ Could not start job on tier 2: ${err2.message}\n`));
496
+ console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.`));
507
497
  }
508
498
  process.exit(1);
509
499
  }
@@ -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
@@ -18,7 +23,6 @@ export function parseServeArgs(args) {
18
23
  if (args[i] === '--count') { flags.count = parseInt(args[++i], 10); i++; continue; }
19
24
  if (args[i] === '--region') { flags.region = args[++i]; i++; continue; }
20
25
  if (args[i] === '--tier') { flags.tier = args[++i]; i++; continue; }
21
- if (args[i] === '--cheap') { flags.cheap = true; i++; continue; }
22
26
  if (args[i] === '--max-price') { flags.maxPrice = parseFloat(args[++i]); i++; continue; }
23
27
  if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
24
28
  if (args[i] === '--no-wait') { flags.noWait = true; i++; continue; }
@@ -70,15 +74,15 @@ export async function serveCommand(config, args, chalk) {
70
74
  const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : 'AUTO';
71
75
  const gpuLabel = gpu === 'AUTO' ? 'auto' : gpu;
72
76
 
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')
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')
75
79
  ? '2'
76
80
  : (flags.tier || '1');
77
81
 
78
82
  console.log(chalk.bold('\nServing model\n'));
79
83
  console.log(` ${chalk.bold('Model:')} ${model}`);
80
84
  console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
81
- if (effectiveTier === '2') console.log(` ${chalk.dim('(budget modesearching all providers)')}`);
85
+ if (effectiveTier === '2') console.log(` ${chalk.dim('(tier 2marketplace routing)')}`);
82
86
  console.log();
83
87
  process.stdout.write(chalk.dim(' Finding GPU capacity...\n'));
84
88
 
@@ -107,46 +111,45 @@ export async function serveCommand(config, args, chalk) {
107
111
  } catch (err) {
108
112
  const d = err.errorData;
109
113
  if (d?.code === 'NO_CAPACITY_MATCH') {
110
- const pool = [
111
- ...(Array.isArray(d.same_gpu_other_regions) ? d.same_gpu_other_regions : []),
112
- ...(Array.isArray(d.alternatives) ? d.alternatives : []),
113
- ];
114
-
115
- if (pool.length === 0) {
116
- console.error(chalk.red(`\n ✗ No GPU capacity available right now.\n`));
114
+ if (effectiveTier === '2') {
115
+ console.error(chalk.red('\n ✗ No GPU capacity available right now on any provider.\n'));
117
116
  console.error(chalk.dim(' Run `badgr capacity` to see alternatives, or try again shortly.'));
118
117
  process.exit(1);
119
118
  }
120
119
 
121
- const ranked = rankAlternatives(gpuLabel, pool, 'closest');
122
- const chosen = await promptFallback(gpuLabel, ranked, chalk);
123
-
124
- if (!chosen) {
125
- console.log(chalk.dim('\n Cancelled.\n'));
126
- process.exit(0);
120
+ // Tier 1 out of capacity — offer tier 2 marketplace routing.
121
+ if (process.stdin.isTTY) {
122
+ const answer = await askConfirm(
123
+ `\n No tier 1 capacity available. Try tier 2 marketplace routing? [${chalk.bold('Enter')}/${chalk.bold('q')}]: `
124
+ );
125
+ if (answer.toLowerCase() === 'q') {
126
+ console.log(chalk.dim('\n Cancelled.\n'));
127
+ process.exit(0);
128
+ }
129
+ } else {
130
+ console.log(chalk.dim('\n No tier 1 capacity — trying tier 2 marketplace routing...\n'));
127
131
  }
128
132
 
129
- console.log(chalk.dim(`\n Trying ${chosen.gpu} in ${chosen.region}...\n`));
130
-
133
+ console.log(chalk.dim(' Searching tier 2 capacity...'));
131
134
  try {
132
135
  dep = await callApi('/serve', {
133
136
  method: 'POST',
134
137
  apiKey: config.apiKey,
135
138
  baseUrl: config.baseUrl,
136
- body: buildBody(chosen.gpu, chosen.region),
139
+ body: { ...buildBody(), tier: '2' },
137
140
  });
138
141
  } catch (err2) {
139
142
  const d2 = err2.errorData;
140
- if (d2?.code === 'PROVISIONING_FAILED' || d2?.code === 'PROVIDER_ADAPTER_ERROR') {
141
- if (d2?.low_cost_provider_failed) {
142
- console.error(chalk.red(`\n Tier 2 unavailable. Tier 1 also unavailable. Try again shortly.\n`));
143
- } else {
144
- console.error(chalk.red(`\n ✗ Badgr found ${chosen.gpu} capacity but could not start the endpoint. Please try again.\n`));
145
- }
143
+ if (d2?.code === 'NO_CAPACITY_MATCH') {
144
+ console.error(chalk.red('\n ✗ No GPU capacity available right now on any provider.\n'));
145
+ console.error(chalk.dim(' Run `badgr capacity` to see alternatives, or try again shortly.'));
146
+ } else if (d2?.code === 'PROVISIONING_FAILED' || d2?.code === 'PROVIDER_ADAPTER_ERROR') {
147
+ console.error(chalk.red(`\n ✗ Budget provider found capacity but could not start the endpoint. Please try again.\n`));
148
+ console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
146
149
  } else {
147
- console.error(chalk.red(`\n ✗ Failed to start endpoint on ${chosen.gpu}: ${err2.message}\n`));
150
+ console.error(chalk.red(`\n ✗ Could not start endpoint on tier 2: ${err2.message}\n`));
151
+ console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
148
152
  }
149
- console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
150
153
  process.exit(1);
151
154
  }
152
155
  } else if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
@@ -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();
@@ -9,6 +9,20 @@ const TEST_COMMAND = ['python', '-c', "print('hello from badgr')"];
9
9
  const TEST_IMAGE = 'python:3.11-slim';
10
10
  const EXPECTED_OUTPUT = 'hello from badgr';
11
11
 
12
+ // --provider flag resolves to a backend tier value.
13
+ // 'tier1' → managed routing (default), 'tier2' → marketplace routing, 'secondary' → secondary dispatch.
14
+ const PROVIDER_TO_TIER = { tier1: '1', tier2: '2', secondary: 'modal' };
15
+
16
+ export function parseTestArgs(args) {
17
+ const flags = {};
18
+ let i = 0;
19
+ while (i < args.length) {
20
+ if (args[i] === '--provider' && args[i + 1]) { flags.provider = args[++i]; i++; continue; }
21
+ i++;
22
+ }
23
+ return flags;
24
+ }
25
+
12
26
  function step(chalk, ok, msg, detail = '') {
13
27
  const icon = ok ? chalk.green('✓') : chalk.red('✗');
14
28
  const suffix = detail ? chalk.dim(` — ${detail}`) : '';
@@ -46,12 +60,40 @@ async function pollLogs(config, depId, expected, timeoutMs) {
46
60
  return false;
47
61
  }
48
62
 
49
- export async function testCommand(config, chalk) {
63
+ export async function testCommand(config, args, chalk) {
50
64
  requireApiKey(config);
51
65
 
66
+ const flags = parseTestArgs(Array.isArray(args) ? args : []);
67
+ const providerKey = flags.provider ? flags.provider.toLowerCase() : 'tier1';
68
+
69
+ if (providerKey === 'secondary') {
70
+ // Secondary dispatch provider uses a webhook model, not direct GPU rental.
71
+ // Verify the backend reports it as configured.
72
+ console.log(chalk.bold('\n⚡ Testing secondary dispatch provider\n'));
73
+ let routes;
74
+ try {
75
+ routes = await callApi('/compute/routes', { apiKey: config.apiKey, baseUrl: config.baseUrl });
76
+ } catch {
77
+ routes = null;
78
+ }
79
+ const secondaryRoute = Array.isArray(routes) ? routes.find(r => r.name === 'modal') : null;
80
+ if (secondaryRoute?.available) {
81
+ step(chalk, true, 'Secondary provider configured');
82
+ console.log(chalk.green('\n ✓ Secondary dispatch provider is ready\n'));
83
+ } else {
84
+ step(chalk, false, 'Secondary provider configured', 'contact support to enable secondary dispatch');
85
+ console.log(chalk.red('\n ✗ Secondary dispatch provider is not configured\n'));
86
+ process.exit(1);
87
+ }
88
+ return;
89
+ }
90
+
91
+ const tier = PROVIDER_TO_TIER[providerKey] ?? '1';
92
+ const tierLabel = tier === '1' ? 'tier 1 (managed routing)' : 'tier 2 (marketplace routing)';
93
+
52
94
  console.log(chalk.bold('\n⚡ Running end-to-end test\n'));
53
95
  console.log(chalk.dim(` Command: ${TEST_COMMAND.join(' ')}`));
54
- console.log(chalk.dim(` Provider: RunPod (Tier 1 — reliable)`));
96
+ console.log(chalk.dim(` Routing: ${tier === '1' ? 'tier 1 — managed provider routing' : 'tier 2 — marketplace routing'}`));
55
97
  console.log(chalk.dim(` Budget: max $${TEST_MAX_PRICE.toFixed(2)}/hr · 2 minute cap (~$0.05 max)`));
56
98
  console.log();
57
99
 
@@ -59,32 +101,50 @@ export async function testCommand(config, chalk) {
59
101
  let depId;
60
102
 
61
103
  // ── 1. Provision ─────────────────────────────────────────────────────────
62
- process.stdout.write(chalk.dim(' Provisioning GPU...'));
104
+ process.stdout.write(chalk.dim(` Provisioning GPU (${tierLabel})...\n`));
63
105
  let dep;
106
+ const baseBody = {
107
+ command: TEST_COMMAND,
108
+ image: TEST_IMAGE,
109
+ gpu: 'RTX_3080',
110
+ max_price_per_hour: TEST_MAX_PRICE,
111
+ };
64
112
  try {
65
113
  dep = await callApi('/run', {
66
114
  method: 'POST',
67
115
  apiKey: config.apiKey,
68
116
  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
- },
117
+ body: { ...baseBody, tier },
77
118
  });
78
- depId = dep.deployment_id;
79
- process.stdout.write('\n');
80
- step(chalk, true, 'Provisioned', `${dep.deployment_id} on ${dep.gpu_type}`);
81
119
  } 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);
120
+ if (err.errorData?.code === 'NO_CAPACITY_MATCH' && tier === '1') {
121
+ process.stdout.write('\n');
122
+ process.stdout.write(chalk.dim(' No tier 1 capacity — trying tier 2 marketplace routing...'));
123
+ try {
124
+ dep = await callApi('/run', {
125
+ method: 'POST',
126
+ apiKey: config.apiKey,
127
+ baseUrl: config.baseUrl,
128
+ body: { ...baseBody, tier: '2' },
129
+ });
130
+ } catch (err2) {
131
+ process.stdout.write('\n');
132
+ step(chalk, false, 'Provisioned', err2.message);
133
+ console.log();
134
+ console.error(chalk.red(' Test failed — no GPU capacity available on any provider.\n'));
135
+ process.exit(1);
136
+ }
137
+ } else {
138
+ process.stdout.write('\n');
139
+ step(chalk, false, 'Provisioned', err.message);
140
+ console.log();
141
+ console.error(chalk.red(' Test failed — could not provision GPU.\n'));
142
+ process.exit(1);
143
+ }
87
144
  }
145
+ depId = dep.deployment_id;
146
+ process.stdout.write('\n');
147
+ step(chalk, true, 'Provisioned', `${dep.deployment_id} on ${dep.gpu_type}`);
88
148
 
89
149
  // ── 2. Container started ─────────────────────────────────────────────────
90
150
  process.stdout.write(chalk.dim(' Waiting for container to start...'));
@@ -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');