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 +1 -1
- package/src/badgr.js +16 -2
- package/src/commands/run.js +67 -13
- package/src/commands/serve.js +23 -12
- package/src/commands/test-run.js +147 -0
- package/tests/commands.test.js +66 -1
package/package.json
CHANGED
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('#
|
|
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
|
|
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': {
|
package/src/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
442
|
+
tier: effectiveTier,
|
|
389
443
|
};
|
|
390
444
|
}
|
|
391
445
|
|
package/src/commands/serve.js
CHANGED
|
@@ -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
|
|
32
|
-
|
|
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() -
|
|
48
|
+
const elapsed = Math.round((Date.now() - startMs) / 1000);
|
|
43
49
|
process.stdout.write(
|
|
44
|
-
`\r ${chalk.dim(`
|
|
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
|
-
|
|
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
|
|
211
|
-
console.log(chalk.
|
|
212
|
-
console.log(chalk.dim(`
|
|
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
|
+
}
|
package/tests/commands.test.js
CHANGED
|
@@ -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', () => {
|