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 +1 -1
- package/src/badgr.js +19 -19
- package/src/commands/run.js +55 -71
- package/src/commands/serve.js +8 -9
- package/src/commands/test-run.js +59 -11
- package/tests/commands.test.js +29 -16
package/package.json
CHANGED
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')}
|
|
21
|
-
${chalk.cyan('badgr run <command>')}
|
|
22
|
-
${chalk.cyan('badgr serve <model>')}
|
|
23
|
-
${chalk.cyan('badgr status')}
|
|
24
|
-
${chalk.cyan('badgr logs <id>')}
|
|
25
|
-
${chalk.cyan('badgr down <id>')}
|
|
26
|
-
${chalk.cyan('badgr receipts')}
|
|
27
|
-
${chalk.cyan('badgr test')}
|
|
28
|
-
${chalk.cyan('badgr capacity')}
|
|
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('#
|
|
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('#
|
|
39
|
-
badgr run python train.py --
|
|
40
|
-
badgr serve meta-llama/Llama-3.1-8B-Instruct --
|
|
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
|
|
57
|
-
--
|
|
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
|
-
--
|
|
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': {
|
package/src/commands/run.js
CHANGED
|
@@ -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'
|
|
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
|
|
89
|
-
const TIMEOUT_MS
|
|
90
|
-
const startMs
|
|
91
|
-
|
|
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
|
|
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
|
-
|
|
130
|
+
process.stdout.write('\n');
|
|
115
131
|
}
|
|
116
132
|
|
|
117
|
-
// Timed out
|
|
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
|
-
|
|
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
|
-
//
|
|
353
|
-
|
|
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('-', '_') :
|
|
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 (
|
|
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('(
|
|
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 2 — marketplace 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('
|
|
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
|
|
430
|
-
const effectiveRegion =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
549
|
-
|
|
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 ──
|
|
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 ✓
|
|
609
|
+
} else if ((finalStatus === 'completed' || finalStatus === 'succeeded') && (exitCode === 0 || exitCode === null)) {
|
|
610
|
+
console.log(chalk.green(`\n ✓ Job complete\n`));
|
|
627
611
|
}
|
|
628
612
|
}
|
package/src/commands/serve.js
CHANGED
|
@@ -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
|
-
//
|
|
79
|
-
const effectiveTier = (flags.
|
|
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('(
|
|
85
|
+
if (effectiveTier === '2') console.log(` ${chalk.dim('(tier 2 — marketplace 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
|
|
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
|
|
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
|
|
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
|
|
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 ✗
|
|
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);
|
package/src/commands/test-run.js
CHANGED
|
@@ -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
|
-
|
|
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(`
|
|
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(
|
|
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: '
|
|
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
|
|
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('
|
|
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: '
|
|
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
|
-
|
|
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
|
}
|
package/tests/commands.test.js
CHANGED
|
@@ -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 --
|
|
208
|
-
const { flags } = parseServeArgs(['my/model', '--
|
|
209
|
-
expect(flags.
|
|
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('--
|
|
207
|
+
it('--tier defaults to undefined when not passed', () => {
|
|
213
208
|
const { flags } = parseServeArgs(['my/model']);
|
|
214
|
-
expect(flags.
|
|
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');
|