badgr-cli 1.0.26 → 1.0.28
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/api.js +18 -0
- package/src/badgr.js +3 -0
- package/src/commands/billing.js +93 -0
- package/src/commands/models.js +25 -6
- package/src/commands/run.js +19 -69
- package/src/commands/serve.js +7 -1
- package/src/commands/test-run.js +36 -9
- package/src/router.js +16 -12
- package/tests/commands.test.js +3 -32
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -59,6 +59,24 @@ export async function callApi(path, { method = 'GET', apiKey, baseUrl, body } =
|
|
|
59
59
|
detail = rawBody || res.statusText || '';
|
|
60
60
|
}
|
|
61
61
|
const isCapacityError = errorData?.code === 'NO_CAPACITY_MATCH';
|
|
62
|
+
if (res.status === 402) {
|
|
63
|
+
// Payment required — format a clear, actionable error
|
|
64
|
+
const d = errorData?.detail ?? errorData ?? {};
|
|
65
|
+
const detailObj = typeof d === 'object' ? d : {};
|
|
66
|
+
const balanceUsd = typeof detailObj.balance_usd === 'number' ? detailObj.balance_usd : null;
|
|
67
|
+
const requiredUsd = typeof detailObj.required_usd === 'number' ? detailObj.required_usd : null;
|
|
68
|
+
const topupUrl = detailObj.topup_url || 'https://aibadgr.com/dashboard#billing';
|
|
69
|
+
|
|
70
|
+
let msg = '\nPayment required.\n';
|
|
71
|
+
if (balanceUsd !== null) msg += `Your balance is $${balanceUsd.toFixed(2)}.`;
|
|
72
|
+
if (requiredUsd !== null) msg += ` This job needs a $${requiredUsd.toFixed(2)} reserve.`;
|
|
73
|
+
msg += '\n\nAdd balance:\n ' + topupUrl + '\n';
|
|
74
|
+
const err = new Error(msg);
|
|
75
|
+
err.errorData = errorData;
|
|
76
|
+
err.httpStatus = 402;
|
|
77
|
+
err.isPaymentRequired = true;
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
62
80
|
const hint =
|
|
63
81
|
res.status === 401 ? '\n Hint: Invalid or missing API key — run: badgr login' :
|
|
64
82
|
res.status === 403 ? '\n Hint: Access denied — check your API key permissions' :
|
package/src/badgr.js
CHANGED
|
@@ -12,6 +12,7 @@ import { serveCommand } from './commands/serve.js';
|
|
|
12
12
|
import { modelsCommand } from './commands/models.js';
|
|
13
13
|
import { capacityCommand } from './commands/capacity.js';
|
|
14
14
|
import { testCommand } from './commands/test-run.js';
|
|
15
|
+
import { billingCommand } from './commands/billing.js';
|
|
15
16
|
|
|
16
17
|
const HELP = `
|
|
17
18
|
${chalk.bold('badgr')} — run or serve GPU workloads from one command
|
|
@@ -26,6 +27,7 @@ ${chalk.bold('COMMANDS')}
|
|
|
26
27
|
${chalk.cyan('badgr receipts')} Show cost history
|
|
27
28
|
${chalk.cyan('badgr test')} Run an end-to-end test (provision → run → teardown)
|
|
28
29
|
${chalk.cyan('badgr capacity')} Check what GPU capacity is available right now
|
|
30
|
+
${chalk.cyan('badgr billing')} Show balance and add funds
|
|
29
31
|
|
|
30
32
|
${chalk.bold('EXAMPLES')}
|
|
31
33
|
${chalk.dim('# Verify the stack works end-to-end:')}
|
|
@@ -104,6 +106,7 @@ async function main() {
|
|
|
104
106
|
case 'models': return modelsCommand(config, chalk);
|
|
105
107
|
case 'capacity': return capacityCommand(config, rest, chalk);
|
|
106
108
|
case 'test': return testCommand(config, rest, chalk);
|
|
109
|
+
case 'billing': return billingCommand(config, rest, chalk);
|
|
107
110
|
// legacy aliases kept for compatibility
|
|
108
111
|
case 'up': return upCommand(config, rest, chalk);
|
|
109
112
|
case 'config': {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { requireApiKey } from '../config.js';
|
|
3
|
+
import { callApi } from '../api.js';
|
|
4
|
+
|
|
5
|
+
const BILLING_HELP = `
|
|
6
|
+
badgr billing — manage your AI Badgr balance
|
|
7
|
+
|
|
8
|
+
COMMANDS
|
|
9
|
+
badgr billing status Show current balance
|
|
10
|
+
badgr billing add <amount> Open checkout to add balance (minimum $10)
|
|
11
|
+
|
|
12
|
+
EXAMPLES
|
|
13
|
+
badgr billing status
|
|
14
|
+
badgr billing add 10
|
|
15
|
+
badgr billing add 20
|
|
16
|
+
badgr billing add 50
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
function openBrowser(url) {
|
|
20
|
+
const platform = process.platform;
|
|
21
|
+
try {
|
|
22
|
+
if (platform === 'darwin') execSync(`open "${url}"`);
|
|
23
|
+
else if (platform === 'win32') execSync(`start "" "${url}"`);
|
|
24
|
+
else execSync(`xdg-open "${url}"`);
|
|
25
|
+
} catch {
|
|
26
|
+
// Silently ignore — we print the URL anyway
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function billingStatus(config, chalk) {
|
|
31
|
+
requireApiKey(config);
|
|
32
|
+
try {
|
|
33
|
+
const apiUrl = config.baseUrl.replace('/v1', '').replace('/api/v1', '');
|
|
34
|
+
const data = await callApi('/api/me', {
|
|
35
|
+
apiKey: config.apiKey,
|
|
36
|
+
baseUrl: apiUrl,
|
|
37
|
+
});
|
|
38
|
+
const credits = data.credits ?? 0;
|
|
39
|
+
const balanceUsd = (credits / 10000).toFixed(2);
|
|
40
|
+
console.log();
|
|
41
|
+
console.log(chalk.bold(' Balance'));
|
|
42
|
+
console.log(` ${chalk.bold(chalk.blue(`$${balanceUsd}`))}`);
|
|
43
|
+
console.log();
|
|
44
|
+
if (credits === 0) {
|
|
45
|
+
console.log(chalk.yellow(' Balance is $0.00. Add balance before making API calls or running GPU jobs.'));
|
|
46
|
+
console.log(chalk.dim(' Add balance: badgr billing add 10'));
|
|
47
|
+
console.log(chalk.dim(' Or visit: https://aibadgr.com/billing/top-up'));
|
|
48
|
+
}
|
|
49
|
+
console.log();
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (err.isPaymentRequired) {
|
|
52
|
+
console.error(err.message);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
console.error(chalk.red(' Could not fetch balance: ' + err.message));
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function billingAdd(config, amount, chalk) {
|
|
61
|
+
requireApiKey(config);
|
|
62
|
+
const amountInt = parseInt(amount, 10);
|
|
63
|
+
if (!amountInt || amountInt < 10) {
|
|
64
|
+
console.error(chalk.red(' Minimum top-up is $10. Example: badgr billing add 10'));
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const url = `https://aibadgr.com/dashboard#billing`;
|
|
69
|
+
console.log();
|
|
70
|
+
console.log(chalk.bold(` Opening dashboard billing to add $${amountInt}...`));
|
|
71
|
+
console.log();
|
|
72
|
+
console.log(` ${chalk.dim(url)}`);
|
|
73
|
+
console.log();
|
|
74
|
+
openBrowser(url);
|
|
75
|
+
console.log(chalk.dim(' Complete payment in your browser, then rerun your command.'));
|
|
76
|
+
console.log();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function billingCommand(config, args, chalk) {
|
|
80
|
+
const [sub, ...rest] = args;
|
|
81
|
+
|
|
82
|
+
if (!sub || sub === '--help' || sub === '-h') {
|
|
83
|
+
console.log(BILLING_HELP);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (sub === 'status') return billingStatus(config, chalk);
|
|
88
|
+
if (sub === 'add') return billingAdd(config, rest[0], chalk);
|
|
89
|
+
|
|
90
|
+
console.error(chalk.red(` Unknown billing command: ${sub}`));
|
|
91
|
+
console.log(chalk.dim(' Run: badgr billing --help'));
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
package/src/commands/models.js
CHANGED
|
@@ -1,19 +1,38 @@
|
|
|
1
|
-
import { listModels } from '../api.js';
|
|
1
|
+
import { listModels, callApi } from '../api.js';
|
|
2
2
|
import { listAll } from '../router.js';
|
|
3
3
|
|
|
4
4
|
export async function modelsCommand(config, chalk) {
|
|
5
|
-
const gpus = listAll();
|
|
6
|
-
|
|
7
5
|
console.log(chalk.bold('\n📦 GPU Options (cheapest first)\n'));
|
|
6
|
+
|
|
7
|
+
let gpus = null;
|
|
8
|
+
if (config.apiKey) {
|
|
9
|
+
try {
|
|
10
|
+
const data = await callApi('/gpus', { apiKey: config.apiKey, baseUrl: config.baseUrl });
|
|
11
|
+
if (data?.gpus?.length) {
|
|
12
|
+
gpus = data.gpus.map(g => ({
|
|
13
|
+
id: g.id,
|
|
14
|
+
name: g.name,
|
|
15
|
+
vramGb: g.vram_gb,
|
|
16
|
+
ratePerHour: g.rate_per_hour,
|
|
17
|
+
})).sort((a, b) => a.ratePerHour - b.ratePerHour);
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
// fallback to local catalog on error
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!gpus) gpus = listAll();
|
|
25
|
+
|
|
8
26
|
console.log(
|
|
9
|
-
` ${'ID'.padEnd(14)} ${'Name'.padEnd(24)} ${'VRAM'.padEnd(8)} Rate/hr`
|
|
27
|
+
` ${'ID'.padEnd(14)} ${'Name'.padEnd(24)} ${'VRAM'.padEnd(8)} Rate/hr (indicative)`
|
|
10
28
|
);
|
|
11
|
-
console.log(` ${'─'.repeat(
|
|
29
|
+
console.log(` ${'─'.repeat(68)}`);
|
|
12
30
|
gpus.forEach(g => {
|
|
13
31
|
console.log(
|
|
14
|
-
` ${chalk.cyan(g.id.padEnd(14))} ${g.name.padEnd(24)} ${`${g.vramGb}GB`.padEnd(8)}
|
|
32
|
+
` ${chalk.cyan(g.id.padEnd(14))} ${g.name.padEnd(24)} ${`${g.vramGb}GB`.padEnd(8)} ~$${g.ratePerHour.toFixed(2)}`
|
|
15
33
|
);
|
|
16
34
|
});
|
|
35
|
+
console.log(chalk.dim(' Actual billing is set at job start — use `badgr run` to see live rates.'));
|
|
17
36
|
|
|
18
37
|
console.log(chalk.bold('\n🤖 LLM Models\n'));
|
|
19
38
|
if (!config.apiKey) {
|
package/src/commands/run.js
CHANGED
|
@@ -89,7 +89,7 @@ async function waitForRunning(config, depId, chalk) {
|
|
|
89
89
|
const TIMEOUT_MS = 5 * 60 * 1000; // 5 min startup grace (image pull + container init)
|
|
90
90
|
const startMs = Date.now();
|
|
91
91
|
const PHASES = [
|
|
92
|
-
{ afterMs: 0, label: ' Starting
|
|
92
|
+
{ afterMs: 0, label: ' Starting container' },
|
|
93
93
|
{ afterMs: 15000, label: ' Pulling image' },
|
|
94
94
|
{ afterMs: 60000, label: ' Starting container' },
|
|
95
95
|
{ afterMs: 180000, label: ' Running command' },
|
|
@@ -285,63 +285,6 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
285
285
|
}
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
-
/**
|
|
289
|
-
* Infer a workload profile name from the command the user wants to run.
|
|
290
|
-
* The backend uses this to enforce a VRAM floor when picking a GPU.
|
|
291
|
-
*
|
|
292
|
-
* Note: \b word boundaries are intentionally avoided for keyword checks
|
|
293
|
-
* because keywords commonly appear inside filenames joined by underscores
|
|
294
|
-
* (e.g. lora_train.py, run_sdxl.py) where `_` is a word character.
|
|
295
|
-
*/
|
|
296
|
-
export function inferWorkload(command) {
|
|
297
|
-
if (!command || command.length === 0) return 'general';
|
|
298
|
-
|
|
299
|
-
const cmd = command.join(' ').toLowerCase();
|
|
300
|
-
|
|
301
|
-
// Trivial one-liner / smoke test
|
|
302
|
-
if (/print\s*\(|['"]hello/.test(cmd) && cmd.length < 80) return 'smoke_test';
|
|
303
|
-
|
|
304
|
-
// LoRA / QLoRA / PEFT fine-tuning
|
|
305
|
-
if (/lora|qlora|finetune|fine[_-]tun|peft/.test(cmd)) return 'lora_finetune';
|
|
306
|
-
|
|
307
|
-
// Diffusion / image generation
|
|
308
|
-
if (/diffusion|stable.?diff|sdxl|sd.?xl|comfyui|a1111|invoke|kohya/.test(cmd)) return 'image_gen';
|
|
309
|
-
|
|
310
|
-
// vLLM / TGI / inference server
|
|
311
|
-
if (/vllm|[^a-z]tgi[^a-z]|^tgi\b|tgi$|text.generation.inference/.test(cmd)) return 'inference_small';
|
|
312
|
-
|
|
313
|
-
// Explicit training script
|
|
314
|
-
if (/\btrain\.py\b/.test(cmd)) return 'lora_finetune';
|
|
315
|
-
|
|
316
|
-
return 'general';
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const WORKLOAD_LABELS = {
|
|
320
|
-
smoke_test: 'smoke test',
|
|
321
|
-
general: 'GPU job',
|
|
322
|
-
lora_finetune: 'fine-tuning (40GB+ VRAM)',
|
|
323
|
-
image_gen: 'image generation',
|
|
324
|
-
inference_small: 'inference (7B–8B model)',
|
|
325
|
-
inference_medium: 'inference (30B–34B model)',
|
|
326
|
-
inference_large: 'inference (70B+ model)',
|
|
327
|
-
};
|
|
328
|
-
|
|
329
|
-
// Ask the backend for the best available GPU for a given workload.
|
|
330
|
-
// Routes by: workload → min VRAM → provider tier → cheapest match.
|
|
331
|
-
// Returns { gpu, region, price, workload, workload_desc } or null when nothing is available.
|
|
332
|
-
async function findAutoGpu(config, chalk, tier = '1', workload = 'general') {
|
|
333
|
-
try {
|
|
334
|
-
const params = new URLSearchParams({ max_price: '10', tier, workload });
|
|
335
|
-
return await callApi(`/capacity/auto?${params}`, {
|
|
336
|
-
apiKey: config.apiKey,
|
|
337
|
-
baseUrl: config.baseUrl,
|
|
338
|
-
});
|
|
339
|
-
} catch (err) {
|
|
340
|
-
if (err.errorData?.code === 'NO_CAPACITY_MATCH') return null;
|
|
341
|
-
throw err;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
288
|
function askConfirm(prompt) {
|
|
346
289
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
347
290
|
return new Promise(resolve => rl.question(prompt, ans => { rl.close(); resolve(ans.trim()); }));
|
|
@@ -359,10 +302,10 @@ export async function runCommand(config, args, chalk) {
|
|
|
359
302
|
requireApiKey(config);
|
|
360
303
|
|
|
361
304
|
const command = positional.length > 0 ? positional : undefined;
|
|
362
|
-
// Use alpine for
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
305
|
+
// Use alpine for trivial one-liners (7MB vs 50MB — much faster pull)
|
|
306
|
+
const cmdStr = command ? command.join(' ') : '';
|
|
307
|
+
const isSmoke = cmdStr.length < 80 && /print\s*\(|['"]hello/i.test(cmdStr);
|
|
308
|
+
const inferredImage = isSmoke ? 'python:3.11-alpine' : 'python:3.11-slim';
|
|
366
309
|
const image = flags.image || (command ? inferredImage : undefined);
|
|
367
310
|
const detach = flags.detach || false;
|
|
368
311
|
const fallbackMode = flags.noFallback ? 'none' : (flags.fallback || 'closest');
|
|
@@ -377,12 +320,8 @@ export async function runCommand(config, args, chalk) {
|
|
|
377
320
|
// ── Auto GPU selection (no --gpu specified) ────────────────────────────────
|
|
378
321
|
let gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : 'auto';
|
|
379
322
|
|
|
380
|
-
// Infer workload from the command so the backend can apply the correct VRAM floor.
|
|
381
|
-
const workload = command ? inferWorkload(command) : 'general';
|
|
382
|
-
const workloadLabel = WORKLOAD_LABELS[workload] || 'GPU job';
|
|
383
|
-
|
|
384
323
|
if (gpu === 'auto') {
|
|
385
|
-
console.log(chalk.bold(
|
|
324
|
+
console.log(chalk.bold('\n⚡ Running GPU job\n'));
|
|
386
325
|
if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
|
|
387
326
|
if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
|
|
388
327
|
if (effectiveTier === '2') console.log(` ${chalk.dim('(tier 2 — marketplace routing)')}`)
|
|
@@ -404,7 +343,7 @@ export async function runCommand(config, args, chalk) {
|
|
|
404
343
|
}
|
|
405
344
|
}
|
|
406
345
|
|
|
407
|
-
console.log(chalk.dim('
|
|
346
|
+
console.log(chalk.dim(' Finding reliable capacity...'));
|
|
408
347
|
if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
|
|
409
348
|
console.log(chalk.dim(` API: ${config.baseUrl}`));
|
|
410
349
|
}
|
|
@@ -492,6 +431,11 @@ export async function runCommand(config, args, chalk) {
|
|
|
492
431
|
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.\n`));
|
|
493
432
|
}
|
|
494
433
|
process.exit(1);
|
|
434
|
+
} else if (err.isPaymentRequired) {
|
|
435
|
+
console.error(chalk.yellow(err.message));
|
|
436
|
+
const rerun = ['badgr run', ...args].join(' ');
|
|
437
|
+
console.error(chalk.dim(`After payment, rerun:\n ${rerun}\n`));
|
|
438
|
+
process.exit(1);
|
|
495
439
|
} else {
|
|
496
440
|
console.error(chalk.red(`\n ✗ Could not start job: ${err.message}\n`));
|
|
497
441
|
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.`));
|
|
@@ -517,6 +461,7 @@ export async function runCommand(config, args, chalk) {
|
|
|
517
461
|
console.log();
|
|
518
462
|
console.log(chalk.bold(` Job ID: ${chalk.cyan(dep.deployment_id)}`));
|
|
519
463
|
console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
|
|
464
|
+
if (dep.workload_desc) console.log(` ${chalk.bold('Workload:')} ${dep.workload_desc}`);
|
|
520
465
|
if (dep.tier) console.log(` ${chalk.bold('Tier:')} ${dep.tier}`);
|
|
521
466
|
if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
|
|
522
467
|
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
@@ -607,6 +552,11 @@ export async function runCommand(config, args, chalk) {
|
|
|
607
552
|
console.log();
|
|
608
553
|
process.exit(exitCode ?? 1);
|
|
609
554
|
} else if ((finalStatus === 'completed' || finalStatus === 'succeeded') && (exitCode === 0 || exitCode === null)) {
|
|
610
|
-
|
|
555
|
+
try {
|
|
556
|
+
await terminateDeployment(config, dep.deployment_id);
|
|
557
|
+
} catch { /* already stopped */ }
|
|
558
|
+
console.log(chalk.green(`\n ✓ Complete`));
|
|
559
|
+
console.log(chalk.dim(` Billing ended`));
|
|
560
|
+
console.log();
|
|
611
561
|
}
|
|
612
562
|
}
|
package/src/commands/serve.js
CHANGED
|
@@ -84,7 +84,7 @@ export async function serveCommand(config, args, chalk) {
|
|
|
84
84
|
console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
|
|
85
85
|
if (effectiveTier === '2') console.log(` ${chalk.dim('(tier 2 — marketplace routing)')}`);
|
|
86
86
|
console.log();
|
|
87
|
-
process.stdout.write(chalk.dim(' Finding
|
|
87
|
+
process.stdout.write(chalk.dim(' Finding reliable capacity...\n'));
|
|
88
88
|
|
|
89
89
|
const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
|
|
90
90
|
|
|
@@ -171,6 +171,12 @@ export async function serveCommand(config, args, chalk) {
|
|
|
171
171
|
failureType: 'infrastructure',
|
|
172
172
|
createdAt: new Date().toISOString(),
|
|
173
173
|
});
|
|
174
|
+
if (err.isPaymentRequired) {
|
|
175
|
+
console.error(chalk.yellow(err.message));
|
|
176
|
+
const rerun = `badgr serve ${args.join(' ')}`;
|
|
177
|
+
console.error(chalk.dim(`After payment, rerun:\n ${rerun}\n`));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
174
180
|
console.error(chalk.red(`\n ✗ Could not start endpoint: ${err.message}`));
|
|
175
181
|
console.error(chalk.dim(`\n Receipt: ${failRcptId}`));
|
|
176
182
|
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
|
package/src/commands/test-run.js
CHANGED
|
@@ -2,8 +2,8 @@ import { requireApiKey } from '../config.js';
|
|
|
2
2
|
import { callApi, terminateDeployment } from '../api.js';
|
|
3
3
|
import { addReceipt, generateReceiptId } from '../store.js';
|
|
4
4
|
|
|
5
|
-
// max $
|
|
6
|
-
const TEST_MAX_PRICE =
|
|
5
|
+
// max $0.80/hr × 2 min ≈ $0.027 total spend cap (smoke / Modal T4 tier)
|
|
6
|
+
const TEST_MAX_PRICE = 0.80;
|
|
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.
|
|
@@ -20,6 +20,7 @@ export function parseTestArgs(args) {
|
|
|
20
20
|
let i = 0;
|
|
21
21
|
while (i < args.length) {
|
|
22
22
|
if (args[i] === '--provider' && args[i + 1]) { flags.provider = args[++i]; i++; continue; }
|
|
23
|
+
if (args[i] === '--no-tier-fallback') { flags.noTierFallback = true; i++; continue; }
|
|
23
24
|
i++;
|
|
24
25
|
}
|
|
25
26
|
return flags;
|
|
@@ -46,20 +47,36 @@ async function pollStatus(config, depId, targetStatuses, timeoutMs) {
|
|
|
46
47
|
return null;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
async function
|
|
50
|
+
async function pollOutputOrDone(config, depId, expected, timeoutMs) {
|
|
50
51
|
const deadline = Date.now() + timeoutMs;
|
|
51
52
|
while (Date.now() < deadline) {
|
|
52
|
-
await new Promise(r => setTimeout(r,
|
|
53
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
53
54
|
try {
|
|
55
|
+
const dep = await callApi(`/deployments/${depId}`, {
|
|
56
|
+
apiKey: config.apiKey,
|
|
57
|
+
baseUrl: config.baseUrl,
|
|
58
|
+
});
|
|
59
|
+
if (dep.status === 'succeeded' || dep.status === 'failed') {
|
|
60
|
+
const data = await callApi(`/deployments/${depId}/logs`, {
|
|
61
|
+
apiKey: config.apiKey,
|
|
62
|
+
baseUrl: config.baseUrl,
|
|
63
|
+
});
|
|
64
|
+
const lines = data?.logs ?? [];
|
|
65
|
+
return {
|
|
66
|
+
done: true,
|
|
67
|
+
ok: dep.status === 'succeeded' && lines.some(l => l.includes(expected)),
|
|
68
|
+
exitCode: dep.exit_code,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
54
71
|
const data = await callApi(`/deployments/${depId}/logs`, {
|
|
55
72
|
apiKey: config.apiKey,
|
|
56
73
|
baseUrl: config.baseUrl,
|
|
57
74
|
});
|
|
58
75
|
const lines = data?.logs ?? [];
|
|
59
|
-
if (lines.some(l => l.includes(expected))) return true;
|
|
76
|
+
if (lines.some(l => l.includes(expected))) return { done: true, ok: true, exitCode: 0 };
|
|
60
77
|
} catch { /* retry */ }
|
|
61
78
|
}
|
|
62
|
-
return false;
|
|
79
|
+
return { done: false, ok: false, exitCode: null };
|
|
63
80
|
}
|
|
64
81
|
|
|
65
82
|
export async function testCommand(config, args, chalk) {
|
|
@@ -119,7 +136,7 @@ export async function testCommand(config, args, chalk) {
|
|
|
119
136
|
body: { ...baseBody, tier },
|
|
120
137
|
});
|
|
121
138
|
} catch (err) {
|
|
122
|
-
if (err.errorData?.code === 'NO_CAPACITY_MATCH' && tier === '1') {
|
|
139
|
+
if (err.errorData?.code === 'NO_CAPACITY_MATCH' && tier === '1' && !flags.noTierFallback) {
|
|
123
140
|
process.stdout.write('\n');
|
|
124
141
|
process.stdout.write(chalk.dim(' No tier 1 capacity — trying tier 2 marketplace routing...'));
|
|
125
142
|
try {
|
|
@@ -136,6 +153,12 @@ export async function testCommand(config, args, chalk) {
|
|
|
136
153
|
console.error(chalk.red(' Test failed — no GPU capacity available on any provider.\n'));
|
|
137
154
|
process.exit(1);
|
|
138
155
|
}
|
|
156
|
+
} else if (err.errorData?.code === 'NO_CAPACITY_MATCH' && flags.noTierFallback) {
|
|
157
|
+
process.stdout.write('\n');
|
|
158
|
+
step(chalk, false, 'Provisioned', 'no tier 1 capacity');
|
|
159
|
+
console.log();
|
|
160
|
+
console.error(chalk.red(' Test failed — no tier 1 capacity (strict mode, no tier 2 fallback).\n'));
|
|
161
|
+
process.exit(1);
|
|
139
162
|
} else {
|
|
140
163
|
process.stdout.write('\n');
|
|
141
164
|
step(chalk, false, 'Provisioned', err.message);
|
|
@@ -152,7 +175,8 @@ export async function testCommand(config, args, chalk) {
|
|
|
152
175
|
process.stdout.write(chalk.dim(' Waiting for container to start...'));
|
|
153
176
|
const started = await pollStatus(
|
|
154
177
|
config, depId,
|
|
155
|
-
|
|
178
|
+
// Modal serverless jobs may skip straight to succeeded when the callback fires.
|
|
179
|
+
new Set(['running', 'starting', 'succeeded', 'failed', 'stopped', 'completed']),
|
|
156
180
|
TEST_MAX_RUNTIME_MS,
|
|
157
181
|
);
|
|
158
182
|
process.stdout.write('\n');
|
|
@@ -168,10 +192,13 @@ export async function testCommand(config, args, chalk) {
|
|
|
168
192
|
|
|
169
193
|
// ── 3. Command output ────────────────────────────────────────────────────
|
|
170
194
|
process.stdout.write(chalk.dim(' Checking command output...'));
|
|
171
|
-
const
|
|
195
|
+
const outputResult = await pollOutputOrDone(config, depId, EXPECTED_OUTPUT, 90_000);
|
|
172
196
|
process.stdout.write('\n');
|
|
197
|
+
const gotOutput = outputResult.ok;
|
|
173
198
|
if (gotOutput) {
|
|
174
199
|
step(chalk, true, 'Command printed output');
|
|
200
|
+
} else if (outputResult.done && outputResult.exitCode !== 0) {
|
|
201
|
+
step(chalk, false, 'Command printed output', `exit ${outputResult.exitCode}`);
|
|
175
202
|
} else {
|
|
176
203
|
step(chalk, false, 'Command printed output', 'not found in logs (logs may be buffered)');
|
|
177
204
|
}
|
package/src/router.js
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GPU catalog — static
|
|
2
|
+
* GPU catalog — static display info only (name, VRAM, tags, indicative rate).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* For live
|
|
7
|
-
*
|
|
4
|
+
* ratePerHour values are indicative market averages for reference display.
|
|
5
|
+
* Actual billing is always set by the backend and returned as cost_per_hour
|
|
6
|
+
* on every deployment response. For live rates, use GET /v1/gpus.
|
|
7
|
+
*
|
|
8
|
+
* Provider routing logic lives entirely in the backend.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
export const GPU_CATALOG = [
|
|
11
|
-
{ id: 'rtx-3080', canonical: 'RTX_3080',
|
|
12
|
-
{ id: 'rtx-
|
|
13
|
-
{ id: '
|
|
14
|
-
{ id: '
|
|
15
|
-
{ id: '
|
|
16
|
-
{ id: '
|
|
17
|
-
{ id: '
|
|
12
|
+
{ id: 'rtx-3080', canonical: 'RTX_3080', name: 'NVIDIA RTX 3080', vramGb: 10, ratePerHour: 0.35, tags: ['inference', 'dev'] },
|
|
13
|
+
{ id: 'rtx-3090', canonical: 'RTX_3090', name: 'NVIDIA RTX 3090', vramGb: 24, ratePerHour: 0.60, tags: ['inference', 'dev'] },
|
|
14
|
+
{ id: 'rtx-4090', canonical: 'RTX_4090', name: 'NVIDIA RTX 4090', vramGb: 24, ratePerHour: 1.10, tags: ['inference', 'training', 'dev'] },
|
|
15
|
+
{ id: 'a4000', canonical: 'A4000', name: 'NVIDIA RTX A4000', vramGb: 16, ratePerHour: 0.50, tags: ['inference', 'dev'] },
|
|
16
|
+
{ id: 'a5000', canonical: 'A5000', name: 'NVIDIA RTX A5000', vramGb: 24, ratePerHour: 0.70, tags: ['inference', 'dev'] },
|
|
17
|
+
{ id: 'l40s', canonical: 'L40S', name: 'NVIDIA L40S', vramGb: 48, ratePerHour: 1.40, tags: ['inference', 'training'] },
|
|
18
|
+
{ id: 'a6000', canonical: 'A6000', name: 'NVIDIA RTX A6000', vramGb: 48, ratePerHour: 1.60, tags: ['inference', 'training'] },
|
|
19
|
+
{ id: 'a100-40gb', canonical: 'A100', name: 'NVIDIA A100 40GB', vramGb: 40, ratePerHour: 1.80, tags: ['training', 'inference'] },
|
|
20
|
+
{ id: 'a100-80gb', canonical: 'A100_80GB', name: 'NVIDIA A100 80GB', vramGb: 80, ratePerHour: 2.50, tags: ['training', 'large-model'] },
|
|
21
|
+
{ id: 'h100', canonical: 'H100', name: 'NVIDIA H100 80GB', vramGb: 80, ratePerHour: 3.50, tags: ['training', 'large-model'] },
|
|
18
22
|
];
|
|
19
23
|
|
|
20
24
|
export function findById(id) {
|
package/tests/commands.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { parseRunArgs, classifyFailure
|
|
2
|
+
import { parseRunArgs, classifyFailure } from '../src/commands/run.js';
|
|
3
3
|
import { parseServeArgs } from '../src/commands/serve.js';
|
|
4
4
|
import { testCommand, parseTestArgs } from '../src/commands/test-run.js';
|
|
5
5
|
import { rankAlternatives, diffDescription, promptFallback } from '../src/fallback.js';
|
|
@@ -232,38 +232,9 @@ describe('parseTestArgs', () => {
|
|
|
232
232
|
it('parses --provider secondary', () => {
|
|
233
233
|
expect(parseTestArgs(['--provider', 'secondary'])).toEqual({ provider: 'secondary' });
|
|
234
234
|
});
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
describe('inferWorkload', () => {
|
|
238
|
-
it('returns general for empty command', () => {
|
|
239
|
-
expect(inferWorkload([])).toBe('general');
|
|
240
|
-
expect(inferWorkload(null)).toBe('general');
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
it('returns smoke_test for short print() one-liners', () => {
|
|
244
|
-
expect(inferWorkload(['python', '-c', "print('hello')"])).toBe('smoke_test');
|
|
245
|
-
expect(inferWorkload(['python', '-c', "print('hello from badgr')"])).toBe('smoke_test');
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
it('returns general for a typical script', () => {
|
|
249
|
-
expect(inferWorkload(['python', 'script.py'])).toBe('general');
|
|
250
|
-
expect(inferWorkload(['python', 'run.py', '--epochs', '10'])).toBe('general');
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it('returns lora_finetune for LoRA/fine-tuning keywords', () => {
|
|
254
|
-
expect(inferWorkload(['python', 'lora_train.py'])).toBe('lora_finetune');
|
|
255
|
-
expect(inferWorkload(['python', '-c', 'import peft; finetune()'])).toBe('lora_finetune');
|
|
256
|
-
expect(inferWorkload(['python', 'train.py', '--epochs', '3'])).toBe('lora_finetune');
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it('returns image_gen for diffusion keywords', () => {
|
|
260
|
-
expect(inferWorkload(['python', 'stable_diff.py'])).toBe('image_gen');
|
|
261
|
-
expect(inferWorkload(['python', 'run_sdxl.py'])).toBe('image_gen');
|
|
262
|
-
});
|
|
263
235
|
|
|
264
|
-
it('
|
|
265
|
-
expect(
|
|
266
|
-
expect(inferWorkload(['python', 'serve_tgi.py'])).toBe('inference_small');
|
|
236
|
+
it('parses --no-tier-fallback', () => {
|
|
237
|
+
expect(parseTestArgs(['--no-tier-fallback'])).toEqual({ noTierFallback: true });
|
|
267
238
|
});
|
|
268
239
|
});
|
|
269
240
|
|