badgr-cli 1.0.21 → 1.0.23
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 +6 -0
- package/src/commands/run.js +82 -46
- package/src/commands/serve.js +44 -36
- package/src/commands/status.js +2 -2
- package/src/commands/test-run.js +165 -0
- package/tests/commands.test.js +41 -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,9 +24,13 @@ ${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')}
|
|
31
|
+
${chalk.dim('# Verify the stack works end-to-end:')}
|
|
32
|
+
badgr test
|
|
33
|
+
|
|
29
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
|
|
@@ -98,6 +103,7 @@ async function main() {
|
|
|
98
103
|
case 'receipts': return receiptsCommand(config, rest, chalk);
|
|
99
104
|
case 'models': return modelsCommand(config, chalk);
|
|
100
105
|
case 'capacity': return capacityCommand(config, rest, chalk);
|
|
106
|
+
case 'test': return testCommand(config, chalk);
|
|
101
107
|
// legacy aliases kept for compatibility
|
|
102
108
|
case 'up': return upCommand(config, rest, chalk);
|
|
103
109
|
case 'config': {
|
package/src/commands/run.js
CHANGED
|
@@ -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
|
|
@@ -270,12 +269,53 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
270
269
|
}
|
|
271
270
|
}
|
|
272
271
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
272
|
+
/**
|
|
273
|
+
* Infer a workload profile name from the command the user wants to run.
|
|
274
|
+
* The backend uses this to enforce a VRAM floor when picking a GPU.
|
|
275
|
+
*
|
|
276
|
+
* Note: \b word boundaries are intentionally avoided for keyword checks
|
|
277
|
+
* because keywords commonly appear inside filenames joined by underscores
|
|
278
|
+
* (e.g. lora_train.py, run_sdxl.py) where `_` is a word character.
|
|
279
|
+
*/
|
|
280
|
+
export function inferWorkload(command) {
|
|
281
|
+
if (!command || command.length === 0) return 'general';
|
|
282
|
+
|
|
283
|
+
const cmd = command.join(' ').toLowerCase();
|
|
284
|
+
|
|
285
|
+
// Trivial one-liner / smoke test
|
|
286
|
+
if (/print\s*\(|['"]hello/.test(cmd) && cmd.length < 80) return 'smoke_test';
|
|
287
|
+
|
|
288
|
+
// LoRA / QLoRA / PEFT fine-tuning
|
|
289
|
+
if (/lora|qlora|finetune|fine[_-]tun|peft/.test(cmd)) return 'lora_finetune';
|
|
290
|
+
|
|
291
|
+
// Diffusion / image generation
|
|
292
|
+
if (/diffusion|stable.?diff|sdxl|sd.?xl|comfyui|a1111|invoke|kohya/.test(cmd)) return 'image_gen';
|
|
293
|
+
|
|
294
|
+
// vLLM / TGI / inference server
|
|
295
|
+
if (/vllm|[^a-z]tgi[^a-z]|^tgi\b|tgi$|text.generation.inference/.test(cmd)) return 'inference_small';
|
|
296
|
+
|
|
297
|
+
// Explicit training script
|
|
298
|
+
if (/\btrain\.py\b/.test(cmd)) return 'lora_finetune';
|
|
299
|
+
|
|
300
|
+
return 'general';
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const WORKLOAD_LABELS = {
|
|
304
|
+
smoke_test: 'smoke test',
|
|
305
|
+
general: 'GPU job',
|
|
306
|
+
lora_finetune: 'fine-tuning (40GB+ VRAM)',
|
|
307
|
+
image_gen: 'image generation',
|
|
308
|
+
inference_small: 'inference (7B–8B model)',
|
|
309
|
+
inference_medium: 'inference (30B–34B model)',
|
|
310
|
+
inference_large: 'inference (70B+ model)',
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// Ask the backend for the best available GPU for a given workload.
|
|
314
|
+
// Routes by: workload → min VRAM → provider tier → cheapest match.
|
|
315
|
+
// Returns { gpu, region, price, workload, workload_desc } or null when nothing is available.
|
|
316
|
+
async function findAutoGpu(config, chalk, tier = '1', workload = 'general') {
|
|
277
317
|
try {
|
|
278
|
-
const params = new URLSearchParams({ max_price: '10', tier });
|
|
318
|
+
const params = new URLSearchParams({ max_price: '10', tier, workload });
|
|
279
319
|
return await callApi(`/capacity/auto?${params}`, {
|
|
280
320
|
apiKey: config.apiKey,
|
|
281
321
|
baseUrl: config.baseUrl,
|
|
@@ -319,17 +359,21 @@ export async function runCommand(config, args, chalk) {
|
|
|
319
359
|
let gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : null;
|
|
320
360
|
let autoRegion = null;
|
|
321
361
|
|
|
362
|
+
// Infer workload from the command so the backend can apply the correct VRAM floor.
|
|
363
|
+
const workload = command ? inferWorkload(command) : 'general';
|
|
364
|
+
const workloadLabel = WORKLOAD_LABELS[workload] || 'GPU job';
|
|
365
|
+
|
|
322
366
|
if (!gpu) {
|
|
323
|
-
console.log(chalk.bold(
|
|
367
|
+
console.log(chalk.bold(`\n⚡ Running ${workloadLabel}\n`));
|
|
324
368
|
if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
|
|
325
369
|
if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
|
|
326
370
|
if (effectiveTier === '2') console.log(` ${chalk.dim('(budget mode — searching all providers)')}`);
|
|
327
371
|
console.log();
|
|
328
|
-
process.stdout.write(chalk.dim(' Finding GPU...'));
|
|
372
|
+
process.stdout.write(chalk.dim(' Finding best GPU...'));
|
|
329
373
|
|
|
330
374
|
let best;
|
|
331
375
|
try {
|
|
332
|
-
best = await findAutoGpu(config, chalk, effectiveTier);
|
|
376
|
+
best = await findAutoGpu(config, chalk, effectiveTier, workload);
|
|
333
377
|
} catch (err) {
|
|
334
378
|
process.stdout.write('\n');
|
|
335
379
|
console.error(chalk.red(`\n ✗ Could not find GPU capacity: ${err.message}\n`));
|
|
@@ -344,17 +388,17 @@ export async function runCommand(config, args, chalk) {
|
|
|
344
388
|
process.exit(1);
|
|
345
389
|
}
|
|
346
390
|
|
|
347
|
-
|
|
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}`);
|
|
348
393
|
console.log();
|
|
349
394
|
|
|
350
|
-
if (process.stdin.isTTY) {
|
|
351
|
-
|
|
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: `);
|
|
352
398
|
if (answer.toLowerCase() === 'q') {
|
|
353
399
|
console.log(chalk.dim('\n Cancelled.\n'));
|
|
354
400
|
process.exit(0);
|
|
355
401
|
}
|
|
356
|
-
} else {
|
|
357
|
-
console.log(chalk.dim(` Auto-selecting ${best.gpu} (non-interactive).`));
|
|
358
402
|
}
|
|
359
403
|
|
|
360
404
|
gpu = best.gpu;
|
|
@@ -409,56 +453,48 @@ export async function runCommand(config, args, chalk) {
|
|
|
409
453
|
} catch (err) {
|
|
410
454
|
const d = err.errorData;
|
|
411
455
|
if (d?.code === 'NO_CAPACITY_MATCH') {
|
|
412
|
-
if (
|
|
413
|
-
console.error(chalk.red(
|
|
414
|
-
console.error(chalk.dim(' Pass --fallback closest to auto-select an alternative.'));
|
|
415
|
-
process.exit(1);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
const pool = [
|
|
419
|
-
...(Array.isArray(d.same_gpu_other_regions) ? d.same_gpu_other_regions : []),
|
|
420
|
-
...(Array.isArray(d.alternatives) ? d.alternatives : []),
|
|
421
|
-
];
|
|
422
|
-
|
|
423
|
-
if (pool.length === 0) {
|
|
424
|
-
console.error(chalk.red(`\n ✗ ${gpu} isn't available right now and no alternatives were found.\n`));
|
|
456
|
+
if (effectiveTier === '2') {
|
|
457
|
+
console.error(chalk.red('\n ✗ No GPU capacity available right now on any provider.\n'));
|
|
425
458
|
console.error(chalk.dim(' Try `badgr capacity` to see what\'s available, or try again shortly.'));
|
|
426
459
|
process.exit(1);
|
|
427
460
|
}
|
|
428
461
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
462
|
+
// Tier 1 (RunPod) is out of capacity — offer budget providers.
|
|
463
|
+
if (process.stdin.isTTY) {
|
|
464
|
+
const answer = await askConfirm(
|
|
465
|
+
`\n RunPod has no suitable capacity. Try budget marketplace GPUs? (Vast.ai, Salad) [${chalk.bold('Enter')}/${chalk.bold('q')}]: `
|
|
466
|
+
);
|
|
467
|
+
if (answer.toLowerCase() === 'q') {
|
|
468
|
+
console.log(chalk.dim('\n Cancelled.\n'));
|
|
469
|
+
process.exit(0);
|
|
470
|
+
}
|
|
471
|
+
} else {
|
|
472
|
+
console.log(chalk.dim('\n RunPod capacity unavailable — falling back to budget providers...\n'));
|
|
435
473
|
}
|
|
436
474
|
|
|
437
|
-
console.log(chalk.dim(
|
|
438
|
-
|
|
475
|
+
console.log(chalk.dim(' Searching budget providers (Vast.ai, Salad)...'));
|
|
439
476
|
try {
|
|
440
477
|
dep = await callApi('/run', {
|
|
441
478
|
method: 'POST',
|
|
442
479
|
apiKey: config.apiKey,
|
|
443
480
|
baseUrl: config.baseUrl,
|
|
444
|
-
body: buildBody(
|
|
481
|
+
body: { ...buildBody(), tier: '2' },
|
|
445
482
|
});
|
|
446
483
|
} catch (err2) {
|
|
447
484
|
const d2 = err2.errorData;
|
|
448
|
-
if (d2?.code === '
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
}
|
|
485
|
+
if (d2?.code === 'NO_CAPACITY_MATCH') {
|
|
486
|
+
console.error(chalk.red('\n ✗ No GPU capacity available right now on any provider.\n'));
|
|
487
|
+
console.error(chalk.dim(' Try `badgr capacity` to see what\'s available, or try again shortly.'));
|
|
488
|
+
} else if (d2?.code === 'PROVISIONING_FAILED' || d2?.code === 'PROVIDER_ADAPTER_ERROR') {
|
|
489
|
+
console.error(chalk.red(`\n ✗ Budget provider found capacity but could not start the machine. Please try again.\n`));
|
|
454
490
|
if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
|
|
455
491
|
if (d2?.debug_error) console.error(chalk.dim(` Provider detail: ${d2.debug_error}`));
|
|
492
|
+
} else {
|
|
493
|
+
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.\n`));
|
|
456
494
|
}
|
|
457
495
|
} else {
|
|
458
|
-
console.error(chalk.red(`\n ✗
|
|
459
|
-
|
|
460
|
-
if (!(process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true')) {
|
|
461
|
-
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.\n`));
|
|
496
|
+
console.error(chalk.red(`\n ✗ Could not start job on budget providers: ${err2.message}\n`));
|
|
497
|
+
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.`));
|
|
462
498
|
}
|
|
463
499
|
process.exit(1);
|
|
464
500
|
}
|
package/src/commands/serve.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
@@ -28,21 +33,26 @@ export function parseServeArgs(args) {
|
|
|
28
33
|
return { model, flags };
|
|
29
34
|
}
|
|
30
35
|
|
|
36
|
+
function _serveStageLabel(elapsedSec) {
|
|
37
|
+
if (elapsedSec < 45) return 'Starting vLLM…';
|
|
38
|
+
if (elapsedSec < 150) return 'Downloading model…';
|
|
39
|
+
return 'Waiting for /v1/models…';
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
async function waitForEndpoint(endpointUrl, timeoutMs = 5 * 60 * 1000, chalk) {
|
|
32
|
-
const
|
|
33
|
-
|
|
43
|
+
const startMs = Date.now();
|
|
44
|
+
const deadline = startMs + timeoutMs;
|
|
34
45
|
|
|
35
46
|
while (Date.now() < deadline) {
|
|
36
|
-
attempt++;
|
|
37
47
|
try {
|
|
38
48
|
const res = await fetch(`${endpointUrl}/models`, { signal: AbortSignal.timeout(8000) });
|
|
39
|
-
if (res.ok) return true;
|
|
49
|
+
if (res.ok) { process.stdout.write('\n'); return true; }
|
|
40
50
|
} catch {
|
|
41
51
|
// still starting
|
|
42
52
|
}
|
|
43
|
-
const elapsed = Math.round((Date.now() -
|
|
53
|
+
const elapsed = Math.round((Date.now() - startMs) / 1000);
|
|
44
54
|
process.stdout.write(
|
|
45
|
-
`\r ${chalk.dim(`
|
|
55
|
+
`\r ${chalk.dim(_serveStageLabel(elapsed) + ` (${elapsed}s)`)} `
|
|
46
56
|
);
|
|
47
57
|
await new Promise(r => setTimeout(r, 8000));
|
|
48
58
|
}
|
|
@@ -102,46 +112,45 @@ export async function serveCommand(config, args, chalk) {
|
|
|
102
112
|
} catch (err) {
|
|
103
113
|
const d = err.errorData;
|
|
104
114
|
if (d?.code === 'NO_CAPACITY_MATCH') {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
...(Array.isArray(d.alternatives) ? d.alternatives : []),
|
|
108
|
-
];
|
|
109
|
-
|
|
110
|
-
if (pool.length === 0) {
|
|
111
|
-
console.error(chalk.red(`\n ✗ No GPU capacity available right now.\n`));
|
|
115
|
+
if (effectiveTier === '2') {
|
|
116
|
+
console.error(chalk.red('\n ✗ No GPU capacity available right now on any provider.\n'));
|
|
112
117
|
console.error(chalk.dim(' Run `badgr capacity` to see alternatives, or try again shortly.'));
|
|
113
118
|
process.exit(1);
|
|
114
119
|
}
|
|
115
120
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
// Tier 1 (RunPod) out of capacity — offer budget providers.
|
|
122
|
+
if (process.stdin.isTTY) {
|
|
123
|
+
const answer = await askConfirm(
|
|
124
|
+
`\n RunPod has no suitable capacity. Try budget marketplace GPUs? (Vast.ai, Salad) [${chalk.bold('Enter')}/${chalk.bold('q')}]: `
|
|
125
|
+
);
|
|
126
|
+
if (answer.toLowerCase() === 'q') {
|
|
127
|
+
console.log(chalk.dim('\n Cancelled.\n'));
|
|
128
|
+
process.exit(0);
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
console.log(chalk.dim('\n RunPod capacity unavailable — falling back to budget providers...\n'));
|
|
122
132
|
}
|
|
123
133
|
|
|
124
|
-
console.log(chalk.dim(
|
|
125
|
-
|
|
134
|
+
console.log(chalk.dim(' Searching budget providers (Vast.ai, Salad)...'));
|
|
126
135
|
try {
|
|
127
136
|
dep = await callApi('/serve', {
|
|
128
137
|
method: 'POST',
|
|
129
138
|
apiKey: config.apiKey,
|
|
130
139
|
baseUrl: config.baseUrl,
|
|
131
|
-
body: buildBody(
|
|
140
|
+
body: { ...buildBody(), tier: '2' },
|
|
132
141
|
});
|
|
133
142
|
} catch (err2) {
|
|
134
143
|
const d2 = err2.errorData;
|
|
135
|
-
if (d2?.code === '
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
144
|
+
if (d2?.code === 'NO_CAPACITY_MATCH') {
|
|
145
|
+
console.error(chalk.red('\n ✗ No GPU capacity available right now on any provider.\n'));
|
|
146
|
+
console.error(chalk.dim(' Run `badgr capacity` to see alternatives, or try again shortly.'));
|
|
147
|
+
} else if (d2?.code === 'PROVISIONING_FAILED' || d2?.code === 'PROVIDER_ADAPTER_ERROR') {
|
|
148
|
+
console.error(chalk.red(`\n ✗ Budget provider found capacity but could not start the endpoint. Please try again.\n`));
|
|
149
|
+
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
|
|
141
150
|
} else {
|
|
142
|
-
console.error(chalk.red(`\n ✗ Failed to start endpoint on
|
|
151
|
+
console.error(chalk.red(`\n ✗ Failed to start endpoint on budget providers: ${err2.message}\n`));
|
|
152
|
+
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
|
|
143
153
|
}
|
|
144
|
-
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
|
|
145
154
|
process.exit(1);
|
|
146
155
|
}
|
|
147
156
|
} else if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
|
|
@@ -212,12 +221,11 @@ export async function serveCommand(config, args, chalk) {
|
|
|
212
221
|
|
|
213
222
|
// ── Result ────────────────────────────────────────────────────────────────
|
|
214
223
|
if (endpointReady) {
|
|
215
|
-
console.log(chalk.green('\n✓ Endpoint ready\n'));
|
|
224
|
+
console.log(chalk.green('\n ✓ Endpoint ready\n'));
|
|
216
225
|
} else {
|
|
217
|
-
console.log(chalk.yellow('\n⏳ Endpoint still starting
|
|
218
|
-
console.log(chalk.
|
|
219
|
-
console.log(chalk.dim(`
|
|
220
|
-
console.log(chalk.dim(` Stop if it never starts: badgr down ${dep.deployment_id}`));
|
|
226
|
+
console.log(chalk.yellow('\n ⏳ Endpoint still starting — model download may still be in progress.\n'));
|
|
227
|
+
console.log(` ${chalk.bold('Stop billing now:')} ${chalk.dim(`badgr down ${dep.deployment_id}`)}`);
|
|
228
|
+
console.log(` ${chalk.bold('Continue watching:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
|
|
221
229
|
console.log();
|
|
222
230
|
}
|
|
223
231
|
|
package/src/commands/status.js
CHANGED
|
@@ -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.
|
|
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.
|
|
77
|
+
const id = d.deployment_id || d.name;
|
|
78
78
|
console.log(` ${chalk.cyan(`badgr down ${id}`)}`);
|
|
79
79
|
}
|
|
80
80
|
console.log();
|
|
@@ -0,0 +1,165 @@
|
|
|
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 (RunPod)...'));
|
|
63
|
+
let dep;
|
|
64
|
+
const baseBody = {
|
|
65
|
+
command: TEST_COMMAND,
|
|
66
|
+
image: TEST_IMAGE,
|
|
67
|
+
gpu: 'RTX_3080',
|
|
68
|
+
max_price_per_hour: TEST_MAX_PRICE,
|
|
69
|
+
};
|
|
70
|
+
try {
|
|
71
|
+
dep = await callApi('/run', {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
apiKey: config.apiKey,
|
|
74
|
+
baseUrl: config.baseUrl,
|
|
75
|
+
body: { ...baseBody, tier: '1' },
|
|
76
|
+
});
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (err.errorData?.code === 'NO_CAPACITY_MATCH') {
|
|
79
|
+
process.stdout.write('\n');
|
|
80
|
+
process.stdout.write(chalk.dim(' RunPod unavailable, trying budget providers...'));
|
|
81
|
+
try {
|
|
82
|
+
dep = await callApi('/run', {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
apiKey: config.apiKey,
|
|
85
|
+
baseUrl: config.baseUrl,
|
|
86
|
+
body: { ...baseBody, tier: '2' },
|
|
87
|
+
});
|
|
88
|
+
} catch (err2) {
|
|
89
|
+
process.stdout.write('\n');
|
|
90
|
+
step(chalk, false, 'Provisioned', err2.message);
|
|
91
|
+
console.log();
|
|
92
|
+
console.error(chalk.red(' Test failed — no GPU capacity available on any provider.\n'));
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
process.stdout.write('\n');
|
|
97
|
+
step(chalk, false, 'Provisioned', err.message);
|
|
98
|
+
console.log();
|
|
99
|
+
console.error(chalk.red(' Test failed — could not provision GPU.\n'));
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
depId = dep.deployment_id;
|
|
104
|
+
process.stdout.write('\n');
|
|
105
|
+
step(chalk, true, 'Provisioned', `${dep.deployment_id} on ${dep.gpu_type}`);
|
|
106
|
+
|
|
107
|
+
// ── 2. Container started ─────────────────────────────────────────────────
|
|
108
|
+
process.stdout.write(chalk.dim(' Waiting for container to start...'));
|
|
109
|
+
const started = await pollStatus(
|
|
110
|
+
config, depId,
|
|
111
|
+
new Set(['running', 'failed', 'stopped', 'completed']),
|
|
112
|
+
TEST_MAX_RUNTIME_MS,
|
|
113
|
+
);
|
|
114
|
+
process.stdout.write('\n');
|
|
115
|
+
|
|
116
|
+
if (!started || started.status === 'failed') {
|
|
117
|
+
step(chalk, false, 'Container started', started?.status ?? 'timeout');
|
|
118
|
+
console.log();
|
|
119
|
+
console.error(chalk.red(' Test failed — container did not start.\n'));
|
|
120
|
+
try { await terminateDeployment(config, depId); } catch { /* best-effort */ }
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
step(chalk, true, 'Container started');
|
|
124
|
+
|
|
125
|
+
// ── 3. Command output ────────────────────────────────────────────────────
|
|
126
|
+
process.stdout.write(chalk.dim(' Checking command output...'));
|
|
127
|
+
const gotOutput = await pollLogs(config, depId, EXPECTED_OUTPUT, 90_000);
|
|
128
|
+
process.stdout.write('\n');
|
|
129
|
+
if (gotOutput) {
|
|
130
|
+
step(chalk, true, 'Command printed output');
|
|
131
|
+
} else {
|
|
132
|
+
step(chalk, false, 'Command printed output', 'not found in logs (logs may be buffered)');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── 4. Stop billing ──────────────────────────────────────────────────────
|
|
136
|
+
process.stdout.write(chalk.dim(' Stopping deployment...'));
|
|
137
|
+
let stopped = false;
|
|
138
|
+
try {
|
|
139
|
+
await terminateDeployment(config, depId);
|
|
140
|
+
stopped = true;
|
|
141
|
+
} catch { /* best-effort */ }
|
|
142
|
+
process.stdout.write('\n');
|
|
143
|
+
step(chalk, stopped, 'Billing stopped');
|
|
144
|
+
|
|
145
|
+
// ── 5. Receipt ───────────────────────────────────────────────────────────
|
|
146
|
+
addReceipt({
|
|
147
|
+
receiptId: rcptId,
|
|
148
|
+
action: 'badgr test',
|
|
149
|
+
deploymentId: depId,
|
|
150
|
+
gpu: dep.gpu_type,
|
|
151
|
+
status: 'test_complete',
|
|
152
|
+
createdAt: new Date().toISOString(),
|
|
153
|
+
});
|
|
154
|
+
step(chalk, true, 'Receipt created', rcptId);
|
|
155
|
+
|
|
156
|
+
// ── Summary ──────────────────────────────────────────────────────────────
|
|
157
|
+
console.log();
|
|
158
|
+
const passed = stopped;
|
|
159
|
+
if (passed) {
|
|
160
|
+
console.log(chalk.green(chalk.bold(' ✓ Test passed\n')));
|
|
161
|
+
} else {
|
|
162
|
+
console.log(chalk.red(chalk.bold(' ✗ Test failed\n')));
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
}
|
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', () => {
|
|
@@ -214,6 +215,45 @@ describe('parseServeArgs', () => {
|
|
|
214
215
|
});
|
|
215
216
|
});
|
|
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
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
217
257
|
describe('promptFallback output', () => {
|
|
218
258
|
it('does not print a Difference line', async () => {
|
|
219
259
|
const lines = [];
|