badgr-cli 1.0.12 → 1.0.14
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 +12 -4
- package/src/badgr.js +46 -50
- package/src/commands/capacity.js +63 -56
- package/src/commands/down.js +26 -23
- package/src/commands/login.js +20 -3
- package/src/commands/run.js +123 -75
- package/src/commands/serve.js +57 -75
- package/src/commands/status.js +35 -48
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "badgr-cli",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Badgr
|
|
3
|
+
"version": "1.0.14",
|
|
4
|
+
"description": "Badgr, run or serve GPU workloads from one command",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"badgr": "
|
|
7
|
+
"badgr": "src/badgr.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node src/badgr.js",
|
|
@@ -21,6 +21,14 @@
|
|
|
21
21
|
"engines": {
|
|
22
22
|
"node": ">=18.0.0"
|
|
23
23
|
},
|
|
24
|
-
"keywords": [
|
|
24
|
+
"keywords": [
|
|
25
|
+
"gpu",
|
|
26
|
+
"cli",
|
|
27
|
+
"ai",
|
|
28
|
+
"compute",
|
|
29
|
+
"modal",
|
|
30
|
+
"gateway",
|
|
31
|
+
"openai"
|
|
32
|
+
],
|
|
25
33
|
"license": "MIT"
|
|
26
34
|
}
|
package/src/badgr.js
CHANGED
|
@@ -15,64 +15,60 @@ import { capacityCommand } from './commands/capacity.js';
|
|
|
15
15
|
const HELP = `
|
|
16
16
|
${chalk.bold('badgr')} — run or serve GPU workloads from one command
|
|
17
17
|
|
|
18
|
-
${chalk.bold('
|
|
19
|
-
${chalk.cyan('badgr login')}
|
|
20
|
-
${chalk.cyan('badgr run <
|
|
21
|
-
${chalk.cyan('badgr serve <model>
|
|
22
|
-
${chalk.cyan('badgr status')}
|
|
23
|
-
${chalk.cyan('badgr logs <id>')}
|
|
24
|
-
${chalk.cyan('badgr down <id>')}
|
|
25
|
-
${chalk.cyan('badgr receipts
|
|
26
|
-
${chalk.cyan('badgr capacity
|
|
27
|
-
|
|
28
|
-
${chalk.bold('badgr run OPTIONS')}
|
|
29
|
-
--gpu <type> GPU: RTX_4090, A100, L40S, H100 (default: RTX_4090)
|
|
30
|
-
--image <image> Docker image (default: python:3.11-slim)
|
|
31
|
-
--count <n> GPU count (default: 1)
|
|
32
|
-
--region US|EU|AU Region preference (default: US)
|
|
33
|
-
--max-price <$/hr> Hard spend cap per GPU-hour
|
|
34
|
-
--name <name> Job name (auto-generated if omitted)
|
|
35
|
-
--fallback closest|cheapest If requested GPU is unavailable, interactively pick the
|
|
36
|
-
closest (default) or cheapest alternative
|
|
37
|
-
--no-fallback Exit immediately when requested GPU is unavailable
|
|
38
|
-
--max-runtime <minutes> Auto-teardown job after N minutes (prevents runaway billing)
|
|
39
|
-
--max-cost <$> Auto-teardown when total spend reaches this amount
|
|
40
|
-
|
|
41
|
-
${chalk.bold('badgr serve OPTIONS')}
|
|
42
|
-
--gpu <type> GPU: L40S, A100, H100, RTX_4090 (default: L40S)
|
|
43
|
-
--count <n> GPU count (default: 1)
|
|
44
|
-
--region US|EU|AU Region preference (default: US)
|
|
45
|
-
--max-price <$/hr> Hard spend cap per GPU-hour
|
|
46
|
-
--name <name> Deployment name (auto-generated if omitted)
|
|
18
|
+
${chalk.bold('COMMANDS')}
|
|
19
|
+
${chalk.cyan('badgr login')} Authenticate with your API key
|
|
20
|
+
${chalk.cyan('badgr run <command>')} Run a one-off GPU job
|
|
21
|
+
${chalk.cyan('badgr serve <model>')} Serve a model with an OpenAI-compatible endpoint
|
|
22
|
+
${chalk.cyan('badgr status')} Show what's running and what's billing
|
|
23
|
+
${chalk.cyan('badgr logs <id>')} Stream logs for a running job or endpoint
|
|
24
|
+
${chalk.cyan('badgr down <id>')} Stop a deployment and end billing
|
|
25
|
+
${chalk.cyan('badgr receipts')} Show cost history
|
|
26
|
+
${chalk.cyan('badgr capacity')} Check what GPU capacity is available right now
|
|
47
27
|
|
|
48
28
|
${chalk.bold('EXAMPLES')}
|
|
49
|
-
|
|
29
|
+
${chalk.dim('# Simplest — Badgr picks the GPU:')}
|
|
30
|
+
badgr run python train.py
|
|
31
|
+
badgr serve meta-llama/Llama-3.1-8B-Instruct
|
|
32
|
+
|
|
33
|
+
${chalk.dim('# Pin a specific GPU:')}
|
|
50
34
|
badgr run python train.py --gpu A100
|
|
51
|
-
badgr run python train.py --gpu A100 --fallback closest
|
|
52
|
-
badgr run python train.py --gpu A100 --fallback cheapest
|
|
53
|
-
badgr run python train.py --gpu A100 --no-fallback
|
|
54
|
-
badgr run --image my/image:latest --gpu L40S
|
|
55
35
|
badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S
|
|
56
|
-
|
|
36
|
+
|
|
37
|
+
${chalk.dim('# Add safety caps:')}
|
|
38
|
+
badgr run python train.py --max-runtime 60 --max-cost 5
|
|
39
|
+
|
|
40
|
+
${chalk.dim('# Manage a running deployment:')}
|
|
57
41
|
badgr status
|
|
58
|
-
badgr logs
|
|
59
|
-
badgr down
|
|
60
|
-
badgr receipts
|
|
61
|
-
badgr receipts dep_abc123
|
|
42
|
+
badgr logs dep-abc123
|
|
43
|
+
badgr down dep-abc123
|
|
44
|
+
badgr receipts dep-abc123
|
|
62
45
|
|
|
63
|
-
${chalk.bold('
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
46
|
+
${chalk.bold('badgr run OPTIONS')}
|
|
47
|
+
--gpu <type> GPU type (default: auto — Badgr picks cheapest available)
|
|
48
|
+
--image <image> Docker image (default: python:3.11-slim)
|
|
49
|
+
--count <n> Number of GPUs (default: 1)
|
|
50
|
+
--region US|EU|AU Region preference
|
|
51
|
+
--max-price <$/hr> Hard spend cap per GPU-hour
|
|
52
|
+
--max-runtime <min> Auto-stop after N minutes (recommended)
|
|
53
|
+
--max-cost <$> Auto-stop when spend reaches this amount
|
|
54
|
+
--detach Return immediately, don't stream logs
|
|
55
|
+
|
|
56
|
+
${chalk.bold('badgr serve OPTIONS')}
|
|
57
|
+
--gpu <type> GPU type (default: auto — inferred from model size)
|
|
58
|
+
--count <n> Number of GPUs (default: 1)
|
|
59
|
+
--region US|EU|AU Region preference
|
|
60
|
+
--max-price <$/hr> Hard spend cap per GPU-hour
|
|
61
|
+
--no-wait Skip endpoint health check
|
|
67
62
|
|
|
68
|
-
${chalk.bold('
|
|
69
|
-
${chalk.dim('
|
|
63
|
+
${chalk.bold('AFTER SERVING')}
|
|
64
|
+
${chalk.dim('Point any OpenAI client at the returned URL:')}
|
|
65
|
+
${chalk.dim(' from openai import OpenAI')}
|
|
66
|
+
${chalk.dim(' client = OpenAI(base_url="<endpoint url>", api_key="<your key>")')}
|
|
67
|
+
${chalk.dim(' client.chat.completions.create(model="<model>", messages=[...])')}
|
|
70
68
|
|
|
71
|
-
${chalk.bold('
|
|
72
|
-
${chalk.dim('
|
|
73
|
-
${chalk.dim('
|
|
74
|
-
${chalk.dim(' BADGR_DEBUG=1 badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S')}
|
|
75
|
-
${chalk.dim(' badgr config # show current baseUrl and key')}
|
|
69
|
+
${chalk.bold('DEBUG')}
|
|
70
|
+
${chalk.dim('BADGR_DEBUG=1 badgr run python train.py # full request/response trace')}
|
|
71
|
+
${chalk.dim('badgr config # show current API config')}
|
|
76
72
|
`;
|
|
77
73
|
|
|
78
74
|
async function main() {
|
package/src/commands/capacity.js
CHANGED
|
@@ -2,15 +2,9 @@ import { requireApiKey } from '../config.js';
|
|
|
2
2
|
import { callApi } from '../api.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* badgr capacity
|
|
6
|
-
* badgr capacity --gpu
|
|
7
|
-
* badgr capacity --gpu A100 --region EU
|
|
8
|
-
* badgr capacity --max-price 5
|
|
9
|
-
*
|
|
10
|
-
* Internal diagnostic command: shows per-provider availability breakdown.
|
|
11
|
-
* Provider display names come from the backend — none are hardcoded here.
|
|
5
|
+
* badgr capacity # show what's available right now (auto)
|
|
6
|
+
* badgr capacity --gpu A100 # check a specific GPU type
|
|
12
7
|
*/
|
|
13
|
-
|
|
14
8
|
function parseCapacityArgs(args) {
|
|
15
9
|
const flags = {};
|
|
16
10
|
let i = 0;
|
|
@@ -27,17 +21,50 @@ export async function capacityCommand(config, args, chalk) {
|
|
|
27
21
|
const flags = parseCapacityArgs(args);
|
|
28
22
|
requireApiKey(config);
|
|
29
23
|
|
|
30
|
-
const gpu = flags.gpu || 'RTX_4090';
|
|
31
24
|
const maxPrice = flags.maxPrice ?? 10;
|
|
32
25
|
|
|
26
|
+
// No --gpu: show cheapest runnable GPU across all types
|
|
27
|
+
if (!flags.gpu) {
|
|
28
|
+
console.log(chalk.bold('\nAvailable GPU capacity\n'));
|
|
29
|
+
process.stdout.write(chalk.dim(' Checking availability...\n'));
|
|
30
|
+
|
|
31
|
+
let data;
|
|
32
|
+
try {
|
|
33
|
+
const params = new URLSearchParams({ max_price: String(maxPrice) });
|
|
34
|
+
if (flags.region) params.set('region', flags.region.toUpperCase());
|
|
35
|
+
data = await callApi(`/capacity/auto?${params}`, {
|
|
36
|
+
apiKey: config.apiKey,
|
|
37
|
+
baseUrl: config.baseUrl,
|
|
38
|
+
});
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (err.errorData?.code === 'NO_CAPACITY_MATCH') {
|
|
41
|
+
console.log(chalk.dim('\n No GPU capacity available right now.\n'));
|
|
42
|
+
console.log(chalk.dim(' Try again in a few minutes, or check a specific GPU with --gpu <type>.\n'));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
console.error(chalk.red(`\n ✗ Capacity check failed: ${err.message}\n`));
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log();
|
|
50
|
+
console.log(` ${chalk.bold('Cheapest available:')} ${chalk.cyan(data.gpu)} in ${data.region} ${chalk.green('$' + data.price.toFixed(2) + '/hr')}`);
|
|
51
|
+
console.log();
|
|
52
|
+
console.log(` ${chalk.bold('Run now:')}`);
|
|
53
|
+
console.log(chalk.cyan(` badgr run python train.py`));
|
|
54
|
+
console.log(chalk.cyan(` badgr serve meta-llama/Llama-3.1-8B-Instruct`));
|
|
55
|
+
console.log();
|
|
56
|
+
console.log(chalk.dim(` Use badgr capacity --gpu A100 to check a specific GPU type.`));
|
|
57
|
+
console.log();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --gpu specified: show availability for that type
|
|
62
|
+
const gpu = flags.gpu.toUpperCase().replace('-', '_');
|
|
33
63
|
const params = new URLSearchParams({ gpu, max_price: String(maxPrice) });
|
|
34
64
|
if (flags.region) params.set('region', flags.region.toUpperCase());
|
|
35
65
|
|
|
36
|
-
console.log(chalk.bold(`\
|
|
37
|
-
|
|
38
|
-
if (flags.region) console.log(` ${chalk.bold('Region:')} ${flags.region.toUpperCase()}`);
|
|
39
|
-
console.log(` ${chalk.bold('Max price:')} $${maxPrice.toFixed(2)}/hr`);
|
|
40
|
-
console.log();
|
|
66
|
+
console.log(chalk.bold(`\nCapacity: ${gpu}\n`));
|
|
67
|
+
process.stdout.write(chalk.dim(' Checking...\n'));
|
|
41
68
|
|
|
42
69
|
let data;
|
|
43
70
|
try {
|
|
@@ -50,55 +77,35 @@ export async function capacityCommand(config, args, chalk) {
|
|
|
50
77
|
process.exit(1);
|
|
51
78
|
}
|
|
52
79
|
|
|
53
|
-
// Per-provider breakdown
|
|
54
|
-
const diags = data.diagnostics ?? [];
|
|
55
|
-
if (diags.length > 0) {
|
|
56
|
-
console.log(chalk.bold(' Provider breakdown:'));
|
|
57
|
-
for (const d of diags) {
|
|
58
|
-
const padded = (d.display_name ?? 'Unknown').padEnd(12);
|
|
59
|
-
if (d.status === 'no_key') {
|
|
60
|
-
console.log(` ${chalk.dim(padded)} ${chalk.dim('disabled — ' + d.reason)}`);
|
|
61
|
-
} else if (d.status === 'error') {
|
|
62
|
-
console.log(` ${chalk.dim(padded)} ${chalk.red('error — ' + d.reason)}`);
|
|
63
|
-
} else if (d.status === 'ok') {
|
|
64
|
-
const price = d.cheapest_price != null ? chalk.green(`cheapest $${d.cheapest_price.toFixed(2)}/hr`) : '';
|
|
65
|
-
console.log(` ${chalk.bold(padded)} ${d.eligible_count} eligible ${price}`);
|
|
66
|
-
} else {
|
|
67
|
-
const reason = d.reason ? chalk.dim(` — ${d.reason}`) : '';
|
|
68
|
-
console.log(` ${chalk.dim(padded)} 0 eligible${reason}`);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
console.log();
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Direct matches
|
|
75
80
|
const matches = data.matches ?? [];
|
|
76
81
|
if (matches.length > 0) {
|
|
77
|
-
console.log(chalk.bold(' Available now
|
|
82
|
+
console.log(chalk.bold('\n Available now:\n'));
|
|
78
83
|
for (const m of matches) {
|
|
79
|
-
console.log(` ${chalk.green('
|
|
84
|
+
console.log(` ${chalk.green('●')} ${m.gpu} in ${m.region} ${chalk.green('$' + m.price.toFixed(2) + '/hr')}`);
|
|
80
85
|
}
|
|
81
86
|
console.log();
|
|
82
|
-
|
|
83
|
-
console.log(
|
|
87
|
+
const regionFlag = flags.region ? ` --region ${flags.region.toUpperCase()}` : '';
|
|
88
|
+
console.log(` ${chalk.bold('Run:')}`);
|
|
89
|
+
console.log(chalk.cyan(` badgr run python train.py --gpu ${gpu}${regionFlag}`));
|
|
90
|
+
console.log();
|
|
84
91
|
} else {
|
|
85
|
-
|
|
86
|
-
}
|
|
92
|
+
const regionLabel = flags.region ? ` in ${flags.region.toUpperCase()}` : '';
|
|
93
|
+
console.log(chalk.dim(`\n No ${gpu} available right now under $${maxPrice.toFixed(2)}/hr${regionLabel}.\n`));
|
|
87
94
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
console.log(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
console.log(
|
|
95
|
+
const alternatives = data.alternatives ?? [];
|
|
96
|
+
if (alternatives.length > 0) {
|
|
97
|
+
console.log(chalk.bold(' Available alternatives:\n'));
|
|
98
|
+
for (const a of alternatives) {
|
|
99
|
+
console.log(` ${chalk.dim('●')} ${a.gpu} in ${a.region} $${a.price.toFixed(2)}/hr`);
|
|
100
|
+
}
|
|
101
|
+
console.log();
|
|
102
|
+
console.log(` ${chalk.bold('Try:')}`);
|
|
103
|
+
for (const a of alternatives.slice(0, 3)) {
|
|
104
|
+
console.log(chalk.cyan(` badgr run python train.py --gpu ${a.gpu}`));
|
|
105
|
+
}
|
|
106
|
+
console.log();
|
|
107
|
+
} else {
|
|
108
|
+
console.log(chalk.dim(' No alternatives found. Try `badgr capacity` to see global availability.\n'));
|
|
100
109
|
}
|
|
101
110
|
}
|
|
102
|
-
|
|
103
|
-
console.log();
|
|
104
111
|
}
|
package/src/commands/down.js
CHANGED
|
@@ -12,43 +12,46 @@ export async function downCommand(config, args, chalk) {
|
|
|
12
12
|
|
|
13
13
|
requireApiKey(config);
|
|
14
14
|
|
|
15
|
-
const localDep
|
|
16
|
-
|
|
17
|
-
// Resolve the deployment ID to send to the backend
|
|
15
|
+
const localDep = findDeployment(idOrName);
|
|
18
16
|
const deploymentId = localDep?.id ?? idOrName;
|
|
19
17
|
|
|
20
|
-
|
|
18
|
+
process.stdout.write(chalk.dim(` Stopping ${deploymentId}...`));
|
|
21
19
|
|
|
22
20
|
let dep;
|
|
23
21
|
try {
|
|
24
22
|
dep = await terminateDeployment(config, deploymentId);
|
|
25
23
|
} catch (err) {
|
|
26
|
-
|
|
24
|
+
process.stdout.write('\n');
|
|
25
|
+
console.error(chalk.red(`\n ✗ Could not stop deployment: ${err.message}\n`));
|
|
27
26
|
return;
|
|
28
27
|
}
|
|
29
28
|
|
|
29
|
+
process.stdout.write('\n');
|
|
30
|
+
|
|
31
|
+
// Compute final cost from the deployment timestamps
|
|
32
|
+
const stoppedAt = dep.stopped_at ?? (Date.now() / 1000);
|
|
33
|
+
const startedAt = dep.started_at ?? stoppedAt;
|
|
34
|
+
const runtimeMin = Math.round((stoppedAt - startedAt) / 60);
|
|
35
|
+
const runtimeHr = (stoppedAt - startedAt) / 3600;
|
|
36
|
+
const finalCost = (dep.cost_per_hour || 0) * runtimeHr;
|
|
37
|
+
|
|
30
38
|
const rcptId = generateReceiptId();
|
|
31
39
|
addReceipt({
|
|
32
|
-
receiptId:
|
|
33
|
-
action:
|
|
34
|
-
deploymentId:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
createdAt: new Date().toISOString(),
|
|
40
|
+
receiptId: rcptId,
|
|
41
|
+
action: 'badgr down',
|
|
42
|
+
deploymentId: dep.deployment_id,
|
|
43
|
+
gpu: dep.gpu_type,
|
|
44
|
+
runtimeSeconds: Math.round(stoppedAt - startedAt),
|
|
45
|
+
finalCost,
|
|
46
|
+
status: 'terminated',
|
|
47
|
+
createdAt: new Date().toISOString(),
|
|
41
48
|
});
|
|
42
49
|
|
|
43
|
-
// Remove from local store
|
|
44
50
|
removeDeployment(idOrName);
|
|
45
51
|
|
|
46
|
-
console.log(chalk.green(
|
|
47
|
-
console.log(
|
|
48
|
-
console.log(` ${chalk.bold('
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
console.log(` ${chalk.bold('Uptime:')} ${uptime} min`);
|
|
52
|
-
}
|
|
53
|
-
console.log(`\n ${chalk.bold('Receipt ID:')} ${chalk.dim(rcptId)}\n`);
|
|
52
|
+
console.log(chalk.green('\n✓ Stopped'));
|
|
53
|
+
console.log(chalk.green(' Billing ended\n'));
|
|
54
|
+
console.log(` ${chalk.bold('Runtime:')} ${runtimeMin}m`);
|
|
55
|
+
if (finalCost > 0) console.log(` ${chalk.bold('Final cost:')} $${finalCost.toFixed(4)}`);
|
|
56
|
+
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}\n`);
|
|
54
57
|
}
|
package/src/commands/login.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { input } from '@inquirer/prompts';
|
|
2
2
|
import { DEFAULTS } from '../config.js';
|
|
3
|
+
import { callApi } from '../api.js';
|
|
3
4
|
|
|
4
5
|
export async function loginCommand(chalk, saveConfigFn) {
|
|
5
|
-
console.log(chalk.bold('\
|
|
6
|
+
console.log(chalk.bold('\nBadgr Login\n'));
|
|
6
7
|
|
|
7
8
|
const apiKey = await input({
|
|
8
9
|
message: 'Enter your Badgr API key:',
|
|
@@ -13,7 +14,23 @@ export async function loginCommand(chalk, saveConfigFn) {
|
|
|
13
14
|
apiKey: apiKey.trim(),
|
|
14
15
|
baseUrl: DEFAULTS.baseUrl,
|
|
15
16
|
});
|
|
16
|
-
|
|
17
|
-
console.log(chalk.
|
|
17
|
+
|
|
18
|
+
console.log(chalk.green('\n✓ Logged in'));
|
|
19
|
+
console.log(chalk.dim(` Config saved to ~/.badgr/config.json`));
|
|
20
|
+
|
|
21
|
+
// Verify the key works against the live API
|
|
22
|
+
try {
|
|
23
|
+
await callApi('/models', { apiKey: config.apiKey, baseUrl: config.baseUrl });
|
|
24
|
+
console.log(chalk.green('✓ API reachable'));
|
|
25
|
+
console.log(chalk.green('✓ API key valid\n'));
|
|
26
|
+
} catch (err) {
|
|
27
|
+
if (err.httpStatus === 401 || err.httpStatus === 403) {
|
|
28
|
+
console.log(chalk.yellow('⚠ API key may be invalid — double-check and run badgr login again\n'));
|
|
29
|
+
} else {
|
|
30
|
+
console.log(chalk.yellow('⚠ Could not reach the API right now — check your internet connection\n'));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
console.log(chalk.dim(` Run ${chalk.cyan('badgr run python train.py')} to launch your first job.\n`));
|
|
18
35
|
return config;
|
|
19
36
|
}
|
package/src/commands/run.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
|
+
import readline from 'readline';
|
|
1
2
|
import { requireApiKey } from '../config.js';
|
|
2
3
|
import { callApi, terminateDeployment } from '../api.js';
|
|
3
4
|
import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
|
|
4
5
|
import { rankAlternatives, promptFallback } from '../fallback.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
* badgr run python train.py
|
|
8
|
+
* badgr run python train.py # gpu=auto, attached
|
|
9
|
+
* badgr run python train.py --gpu A100 # specific GPU
|
|
8
10
|
* badgr run --image my/image:latest --gpu L40S --detach
|
|
9
|
-
*
|
|
10
|
-
* Attaches by default: polls status + streams logs until the job finishes,
|
|
11
|
-
* then exits with the job's exit code. Pass --detach to return immediately.
|
|
12
11
|
*/
|
|
13
12
|
export function parseRunArgs(args) {
|
|
14
13
|
const flags = {};
|
|
@@ -38,26 +37,15 @@ function fmtRuntime(ms) {
|
|
|
38
37
|
return `${m}m ${s % 60}s`;
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
const HEARTBEAT_KILL_POLLS = 15; // ~60s → treat as infrastructure failure
|
|
40
|
+
const HEARTBEAT_WARN_POLLS = 3;
|
|
41
|
+
const HEARTBEAT_KILL_POLLS = 15;
|
|
44
42
|
|
|
45
|
-
/**
|
|
46
|
-
* Classify a failure into 'customer_code' or 'infrastructure'.
|
|
47
|
-
* customer_code: process ran and exited non-zero.
|
|
48
|
-
* infrastructure: provider issue — job never completed normally.
|
|
49
|
-
*/
|
|
50
43
|
export function classifyFailure(finalStatus, exitCode) {
|
|
51
44
|
if (finalStatus === 'failed' && (exitCode === null || exitCode === undefined)) return 'infrastructure';
|
|
52
45
|
if (exitCode !== null && exitCode !== undefined && exitCode !== 0) return 'customer_code';
|
|
53
46
|
return null;
|
|
54
47
|
}
|
|
55
48
|
|
|
56
|
-
/**
|
|
57
|
-
* Poll until terminal status, streaming new log lines.
|
|
58
|
-
* Returns { status, exitCode, runtimeMs, failureType }.
|
|
59
|
-
* Calls onTeardown(reason) when SIGINT, maxRuntime, maxCost, or heartbeat loss hits.
|
|
60
|
-
*/
|
|
61
49
|
async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost = null, ratePerHour = 0, onTeardown }) {
|
|
62
50
|
const TERMINAL = new Set(['stopped', 'failed', 'completed']);
|
|
63
51
|
const POLL_MS = 4000;
|
|
@@ -81,14 +69,12 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
81
69
|
const elapsedMs = Date.now() - startMs;
|
|
82
70
|
const spentSoFar = ratePerHour * (elapsedMs / 3_600_000);
|
|
83
71
|
|
|
84
|
-
// Hard spend cap
|
|
85
72
|
if (maxCost !== null && spentSoFar >= maxCost) {
|
|
86
73
|
tearing = true;
|
|
87
74
|
onTeardown('max-cost');
|
|
88
75
|
return { status: 'timeout', exitCode: null, runtimeMs: elapsedMs, failureType: null };
|
|
89
76
|
}
|
|
90
77
|
|
|
91
|
-
// Hard runtime cap
|
|
92
78
|
if (maxRuntimeMs !== null && elapsedMs >= maxRuntimeMs) {
|
|
93
79
|
tearing = true;
|
|
94
80
|
onTeardown('max-runtime');
|
|
@@ -107,7 +93,7 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
107
93
|
if (lastStatus === 'running') {
|
|
108
94
|
const lostSec = Math.round((consecutiveErrs * POLL_MS) / 1000);
|
|
109
95
|
if (consecutiveErrs === HEARTBEAT_WARN_POLLS) {
|
|
110
|
-
console.log(chalk.yellow(`\n ⚠ No heartbeat for ${lostSec}s —
|
|
96
|
+
console.log(chalk.yellow(`\n ⚠ No heartbeat for ${lostSec}s — cloud machine may be unresponsive`));
|
|
111
97
|
} else if (consecutiveErrs >= HEARTBEAT_KILL_POLLS) {
|
|
112
98
|
tearing = true;
|
|
113
99
|
onTeardown('heartbeat-lost');
|
|
@@ -155,41 +141,103 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
155
141
|
}
|
|
156
142
|
}
|
|
157
143
|
|
|
144
|
+
// Ask the backend for the cheapest available GPU right now.
|
|
145
|
+
// Returns { gpu, region, price } or null when nothing is available.
|
|
146
|
+
async function findAutoGpu(config, chalk) {
|
|
147
|
+
try {
|
|
148
|
+
const params = new URLSearchParams({ max_price: '10' });
|
|
149
|
+
return await callApi(`/capacity/auto?${params}`, {
|
|
150
|
+
apiKey: config.apiKey,
|
|
151
|
+
baseUrl: config.baseUrl,
|
|
152
|
+
});
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (err.errorData?.code === 'NO_CAPACITY_MATCH') return null;
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function askConfirm(prompt) {
|
|
160
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
161
|
+
return new Promise(resolve => rl.question(prompt, ans => { rl.close(); resolve(ans.trim()); }));
|
|
162
|
+
}
|
|
163
|
+
|
|
158
164
|
export async function runCommand(config, args, chalk) {
|
|
159
165
|
const { flags, positional } = parseRunArgs(args);
|
|
160
166
|
|
|
161
167
|
if (positional.length === 0 && !flags.image) {
|
|
162
|
-
console.error(chalk.red('Usage: badgr run <command...>
|
|
163
|
-
console.error(chalk.red(' badgr run --image my/image:latest
|
|
168
|
+
console.error(chalk.red('Usage: badgr run <command...>'));
|
|
169
|
+
console.error(chalk.red(' badgr run --image my/image:latest'));
|
|
164
170
|
return;
|
|
165
171
|
}
|
|
166
172
|
|
|
167
173
|
requireApiKey(config);
|
|
168
174
|
|
|
169
|
-
const command
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
175
|
+
const command = positional.length > 0 ? positional : undefined;
|
|
176
|
+
const image = flags.image || (command ? 'python:3.11-slim' : undefined);
|
|
177
|
+
const detach = flags.detach || false;
|
|
178
|
+
const fallbackMode = flags.noFallback ? 'none' : (flags.fallback || 'closest');
|
|
179
|
+
const maxRuntimeMs = flags.maxRuntime ? flags.maxRuntime * 60 * 1000 : null;
|
|
180
|
+
const maxCost = flags.maxCost ?? null;
|
|
181
|
+
|
|
182
|
+
// ── Auto GPU selection (no --gpu specified) ────────────────────────────────
|
|
183
|
+
let gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : null;
|
|
184
|
+
let autoRegion = null;
|
|
185
|
+
|
|
186
|
+
if (!gpu) {
|
|
187
|
+
console.log(chalk.bold('\n⚡ Running GPU job\n'));
|
|
188
|
+
if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
|
|
189
|
+
if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
|
|
190
|
+
console.log();
|
|
191
|
+
process.stdout.write(chalk.dim(' Finding GPU...'));
|
|
192
|
+
|
|
193
|
+
let best;
|
|
194
|
+
try {
|
|
195
|
+
best = await findAutoGpu(config, chalk);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
process.stdout.write('\n');
|
|
198
|
+
console.error(chalk.red(`\n ✗ Could not find GPU capacity: ${err.message}\n`));
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
186
201
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
202
|
+
process.stdout.write('\n');
|
|
203
|
+
|
|
204
|
+
if (!best) {
|
|
205
|
+
console.error(chalk.red('\n ✗ No GPU capacity available right now.\n'));
|
|
206
|
+
console.error(chalk.dim(' Run `badgr capacity` for details, or try again in a few minutes.'));
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
console.log(`\n ${chalk.bold('Best match:')} ${chalk.cyan(best.gpu)} in ${best.region} — ${chalk.green('$' + best.price.toFixed(2) + '/hr')} estimated`);
|
|
211
|
+
console.log();
|
|
212
|
+
|
|
213
|
+
if (process.stdin.isTTY) {
|
|
214
|
+
const answer = await askConfirm(` Press ${chalk.bold('Enter')} to run, or ${chalk.bold('q')} to cancel: `);
|
|
215
|
+
if (answer.toLowerCase() === 'q') {
|
|
216
|
+
console.log(chalk.dim('\n Cancelled.\n'));
|
|
217
|
+
process.exit(0);
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
console.log(chalk.dim(` Auto-selecting ${best.gpu} (non-interactive).`));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
gpu = best.gpu;
|
|
224
|
+
autoRegion = best.region;
|
|
225
|
+
console.log();
|
|
226
|
+
} else {
|
|
227
|
+
// Specific GPU requested — show header
|
|
228
|
+
console.log(chalk.bold('\n⚡ Running GPU job\n'));
|
|
229
|
+
if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
|
|
230
|
+
if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
|
|
231
|
+
console.log(` ${chalk.bold('GPU:')} ${gpu}`);
|
|
232
|
+
if (flags.maxRuntime) console.log(` ${chalk.bold('Max runtime:')} ${flags.maxRuntime}min`);
|
|
233
|
+
if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
|
|
234
|
+
if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice.toFixed(2)}/hr`);
|
|
235
|
+
if (detach) console.log(` ${chalk.dim('(detached — returns immediately)')}`);
|
|
236
|
+
console.log();
|
|
237
|
+
|
|
238
|
+
if (!detach && !flags.maxRuntime && !maxCost) {
|
|
239
|
+
console.log(chalk.dim(' Tip: add --max-runtime 60 or --max-cost 5.00 to cap spend automatically'));
|
|
240
|
+
}
|
|
193
241
|
}
|
|
194
242
|
|
|
195
243
|
console.log(chalk.dim(' Finding best available GPU capacity...'));
|
|
@@ -198,11 +246,13 @@ export async function runCommand(config, args, chalk) {
|
|
|
198
246
|
}
|
|
199
247
|
|
|
200
248
|
function buildBody(gpuOverride, regionOverride) {
|
|
201
|
-
const effectiveRegion = regionOverride
|
|
249
|
+
const effectiveRegion = regionOverride
|
|
250
|
+
?? autoRegion
|
|
251
|
+
?? (flags.region ? flags.region.toUpperCase() : undefined);
|
|
202
252
|
return {
|
|
203
253
|
command,
|
|
204
254
|
image,
|
|
205
|
-
gpu: (gpuOverride || gpu)
|
|
255
|
+
gpu: (gpuOverride || gpu),
|
|
206
256
|
gpu_count: flags.count || 1,
|
|
207
257
|
...(effectiveRegion ? { region: effectiveRegion } : {}),
|
|
208
258
|
max_price_per_hour: flags.maxPrice,
|
|
@@ -227,15 +277,14 @@ export async function runCommand(config, args, chalk) {
|
|
|
227
277
|
process.exit(1);
|
|
228
278
|
}
|
|
229
279
|
|
|
230
|
-
// Merge same-GPU-other-regions and cross-GPU alternatives into one ranked list.
|
|
231
280
|
const pool = [
|
|
232
281
|
...(Array.isArray(d.same_gpu_other_regions) ? d.same_gpu_other_regions : []),
|
|
233
282
|
...(Array.isArray(d.alternatives) ? d.alternatives : []),
|
|
234
283
|
];
|
|
235
284
|
|
|
236
285
|
if (pool.length === 0) {
|
|
237
|
-
console.error(chalk.red(`\n ✗ ${gpu} isn't available right now and no alternatives found.\n`));
|
|
238
|
-
console.error(chalk.dim(' Try
|
|
286
|
+
console.error(chalk.red(`\n ✗ ${gpu} isn't available right now and no alternatives were found.\n`));
|
|
287
|
+
console.error(chalk.dim(' Try `badgr capacity` to see what\'s available, or try again shortly.'));
|
|
239
288
|
process.exit(1);
|
|
240
289
|
}
|
|
241
290
|
|
|
@@ -247,7 +296,7 @@ export async function runCommand(config, args, chalk) {
|
|
|
247
296
|
process.exit(0);
|
|
248
297
|
}
|
|
249
298
|
|
|
250
|
-
console.log(chalk.dim(`\n
|
|
299
|
+
console.log(chalk.dim(`\n Trying ${chosen.gpu} in ${chosen.region}...\n`));
|
|
251
300
|
|
|
252
301
|
try {
|
|
253
302
|
dep = await callApi('/run', {
|
|
@@ -259,19 +308,21 @@ export async function runCommand(config, args, chalk) {
|
|
|
259
308
|
} catch (err2) {
|
|
260
309
|
const d2 = err2.errorData;
|
|
261
310
|
if (d2?.code === 'PROVISIONING_FAILED' || d2?.code === 'PROVIDER_ADAPTER_ERROR') {
|
|
262
|
-
console.error(chalk.red(`\n ✗ Badgr found ${chosen.gpu} capacity
|
|
263
|
-
if (d2.message) console.error(chalk.dim(` Detail: ${d2.message}`));
|
|
311
|
+
console.error(chalk.red(`\n ✗ Badgr found ${chosen.gpu} capacity but could not start the machine. Please try again.\n`));
|
|
264
312
|
} else {
|
|
265
|
-
console.error(chalk.red(`\n ✗ Job failed to start on ${chosen.gpu}: ${err2.message}`));
|
|
313
|
+
console.error(chalk.red(`\n ✗ Job failed to start on ${chosen.gpu}: ${err2.message}\n`));
|
|
266
314
|
}
|
|
267
|
-
console.error(chalk.dim(
|
|
315
|
+
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.\n`));
|
|
268
316
|
process.exit(1);
|
|
269
317
|
}
|
|
318
|
+
} else if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
|
|
319
|
+
console.error(chalk.red(`\n ✗ Badgr found ${gpu} capacity but could not start the machine. Please try again.\n`));
|
|
320
|
+
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.\n`));
|
|
321
|
+
process.exit(1);
|
|
270
322
|
} else {
|
|
271
|
-
console.error(chalk.red(`\n ✗
|
|
272
|
-
console.error(chalk.dim(
|
|
273
|
-
console.error(chalk.dim(`
|
|
274
|
-
console.error(chalk.dim(` Docs: badgr --help\n`));
|
|
323
|
+
console.error(chalk.red(`\n ✗ Could not start job: ${err.message}\n`));
|
|
324
|
+
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.`));
|
|
325
|
+
console.error(chalk.dim(` Check config: badgr config\n`));
|
|
275
326
|
process.exit(1);
|
|
276
327
|
}
|
|
277
328
|
}
|
|
@@ -281,13 +332,13 @@ export async function runCommand(config, args, chalk) {
|
|
|
281
332
|
receiptId: rcptId,
|
|
282
333
|
action: 'badgr run',
|
|
283
334
|
deploymentId: dep.deployment_id,
|
|
284
|
-
provider: dep.provider,
|
|
285
335
|
gpu: dep.gpu_type,
|
|
286
336
|
status: dep.status,
|
|
287
337
|
createdAt: new Date().toISOString(),
|
|
288
338
|
});
|
|
289
339
|
|
|
290
|
-
console.log(
|
|
340
|
+
console.log();
|
|
341
|
+
console.log(chalk.bold(` Job ID: ${chalk.cyan(dep.deployment_id)}`));
|
|
291
342
|
console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
|
|
292
343
|
if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
|
|
293
344
|
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
@@ -298,16 +349,14 @@ export async function runCommand(config, args, chalk) {
|
|
|
298
349
|
return;
|
|
299
350
|
}
|
|
300
351
|
|
|
301
|
-
// ── Attached mode: stream logs until job completes ────────────────────────
|
|
302
352
|
const ratePerHour = dep.cost_per_hour || 0;
|
|
303
|
-
console.log(chalk.dim('\n ──
|
|
353
|
+
console.log(chalk.dim('\n ── Streaming logs (Ctrl+C to stop job) ─────────────────────────\n'));
|
|
304
354
|
|
|
305
|
-
// Teardown helper — called on SIGINT, max-runtime, max-cost, or heartbeat loss.
|
|
306
355
|
async function teardown(reason) {
|
|
307
356
|
const labels = {
|
|
308
357
|
'max-runtime': chalk.yellow('\n ⏱ Max runtime reached — stopping job...'),
|
|
309
358
|
'max-cost': chalk.yellow('\n 💰 Spend cap reached — stopping job...'),
|
|
310
|
-
'heartbeat-lost': chalk.red('\n ✗
|
|
359
|
+
'heartbeat-lost': chalk.red('\n ✗ No response from machine — stopping job...'),
|
|
311
360
|
'interrupted': chalk.yellow('\n Stopping job...'),
|
|
312
361
|
};
|
|
313
362
|
console.log(labels[reason] ?? chalk.yellow('\n Stopping job...'));
|
|
@@ -318,8 +367,7 @@ export async function runCommand(config, args, chalk) {
|
|
|
318
367
|
}
|
|
319
368
|
const runtimeMs = Date.now() - attachStart;
|
|
320
369
|
const finalCost = ratePerHour * (runtimeMs / 3_600_000);
|
|
321
|
-
|
|
322
|
-
updateReceipt(rcptId, { status: reason, runtimeSeconds: Math.round(runtimeMs / 1000), finalCost, failureType });
|
|
370
|
+
updateReceipt(rcptId, { status: reason, runtimeSeconds: Math.round(runtimeMs / 1000), finalCost });
|
|
323
371
|
console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Est. cost: $${finalCost.toFixed(4)}`));
|
|
324
372
|
console.log(chalk.dim(' Job stopped. Billing ended.\n'));
|
|
325
373
|
process.exit(reason === 'interrupted' ? 0 : 1);
|
|
@@ -345,7 +393,6 @@ export async function runCommand(config, args, chalk) {
|
|
|
345
393
|
failureType,
|
|
346
394
|
});
|
|
347
395
|
|
|
348
|
-
// ── Job summary ──────────────────────────────────────────────────────────
|
|
349
396
|
console.log(` ${chalk.bold('Runtime:')} ${fmtRuntime(runtimeMs)}`);
|
|
350
397
|
if (ratePerHour > 0) {
|
|
351
398
|
console.log(` ${chalk.bold('Cost:')} $${finalCost.toFixed(4)} (${chalk.dim(`$${ratePerHour.toFixed(2)}/hr`)})`);
|
|
@@ -353,18 +400,19 @@ export async function runCommand(config, args, chalk) {
|
|
|
353
400
|
if (exitCode !== null && exitCode !== undefined) {
|
|
354
401
|
console.log(` ${chalk.bold('Exit code:')} ${exitCode !== 0 ? chalk.red(exitCode) : chalk.green(exitCode)}`);
|
|
355
402
|
}
|
|
403
|
+
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
356
404
|
|
|
357
405
|
if (finalStatus === 'failed' || (exitCode !== null && exitCode !== 0)) {
|
|
358
|
-
const label = failureType === 'infrastructure'
|
|
359
|
-
? chalk.red(`\n ✗ Infrastructure failure (${dep.deployment_id}) — not your code\n`)
|
|
360
|
-
: chalk.red(`\n ✗ Job failed (${dep.deployment_id})\n`);
|
|
361
|
-
console.error(label);
|
|
362
406
|
if (failureType === 'infrastructure') {
|
|
363
|
-
console.error(chalk.
|
|
407
|
+
console.error(chalk.red(`\n ✗ Machine failure — this is not your code.\n`));
|
|
408
|
+
console.error(chalk.dim(' Contact support with your receipt ID for a refund.'));
|
|
409
|
+
} else {
|
|
410
|
+
console.error(chalk.red(`\n ✗ Job failed (exit ${exitCode ?? 'unknown'})\n`));
|
|
411
|
+
console.error(chalk.dim(` Check logs: badgr logs ${dep.deployment_id}`));
|
|
364
412
|
}
|
|
365
|
-
console.
|
|
413
|
+
console.log();
|
|
366
414
|
process.exit(exitCode ?? 1);
|
|
367
415
|
} else {
|
|
368
|
-
console.log(chalk.green(`\n ✓
|
|
416
|
+
console.log(chalk.green(`\n ✓ Complete\n`));
|
|
369
417
|
}
|
|
370
418
|
}
|
package/src/commands/serve.js
CHANGED
|
@@ -4,11 +4,10 @@ import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../
|
|
|
4
4
|
import { rankAlternatives, promptFallback } from '../fallback.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
+
* badgr serve meta-llama/Llama-3.1-8B-Instruct
|
|
7
8
|
* badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
* "Endpoint ready", and returns an OpenAI-compatible base URL.
|
|
11
|
-
* Stop billing with `badgr down <id>`.
|
|
10
|
+
* GPU defaults to "AUTO" — backend infers from model size.
|
|
12
11
|
*/
|
|
13
12
|
export function parseServeArgs(args) {
|
|
14
13
|
const flags = {};
|
|
@@ -27,24 +26,21 @@ export function parseServeArgs(args) {
|
|
|
27
26
|
return { model, flags };
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
// Poll GET <endpointUrl>/models until it returns 200 or timeout expires.
|
|
31
|
-
// Returns true if healthy, false if timed out.
|
|
32
29
|
async function waitForEndpoint(endpointUrl, timeoutMs = 5 * 60 * 1000, chalk) {
|
|
33
30
|
const deadline = Date.now() + timeoutMs;
|
|
34
|
-
const modelsUrl = `${endpointUrl}/models`;
|
|
35
31
|
let attempt = 0;
|
|
36
32
|
|
|
37
33
|
while (Date.now() < deadline) {
|
|
38
34
|
attempt++;
|
|
39
35
|
try {
|
|
40
|
-
const res = await fetch(
|
|
36
|
+
const res = await fetch(`${endpointUrl}/models`, { signal: AbortSignal.timeout(8000) });
|
|
41
37
|
if (res.ok) return true;
|
|
42
38
|
} catch {
|
|
43
|
-
//
|
|
39
|
+
// still starting
|
|
44
40
|
}
|
|
45
41
|
const elapsed = Math.round((Date.now() - (deadline - timeoutMs)) / 1000);
|
|
46
42
|
process.stdout.write(
|
|
47
|
-
`\r ${chalk.dim(`
|
|
43
|
+
`\r ${chalk.dim(`Health-checking /v1/models… ${elapsed}s`)} `
|
|
48
44
|
);
|
|
49
45
|
await new Promise(r => setTimeout(r, 8000));
|
|
50
46
|
}
|
|
@@ -56,40 +52,43 @@ export async function serveCommand(config, args, chalk) {
|
|
|
56
52
|
const { model, flags } = parseServeArgs(args);
|
|
57
53
|
|
|
58
54
|
if (!model) {
|
|
59
|
-
console.error(chalk.red('Usage: badgr serve <model>
|
|
60
|
-
console.error(chalk.red(' badgr serve meta-llama/Llama-3.1-8B-Instruct
|
|
55
|
+
console.error(chalk.red('Usage: badgr serve <model>'));
|
|
56
|
+
console.error(chalk.red(' badgr serve meta-llama/Llama-3.1-8B-Instruct'));
|
|
61
57
|
return;
|
|
62
58
|
}
|
|
63
59
|
|
|
64
60
|
requireApiKey(config);
|
|
65
61
|
|
|
66
|
-
|
|
62
|
+
// gpu=AUTO tells the backend to infer the right GPU from model size
|
|
63
|
+
const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : 'AUTO';
|
|
64
|
+
const gpuLabel = gpu === 'AUTO' ? 'auto' : gpu;
|
|
67
65
|
|
|
68
|
-
console.log(chalk.bold('\
|
|
66
|
+
console.log(chalk.bold('\nServing model\n'));
|
|
69
67
|
console.log(` ${chalk.bold('Model:')} ${model}`);
|
|
70
|
-
console.log(` ${chalk.bold('GPU:')} ${
|
|
68
|
+
console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
|
|
71
69
|
console.log();
|
|
72
|
-
|
|
73
|
-
if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
|
|
74
|
-
console.log(chalk.dim(` API: ${config.baseUrl}`));
|
|
75
|
-
}
|
|
70
|
+
process.stdout.write(chalk.dim(' Finding GPU capacity...\n'));
|
|
76
71
|
|
|
77
72
|
const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
|
|
78
73
|
|
|
74
|
+
function buildBody(gpuOverride, regionOverride) {
|
|
75
|
+
return {
|
|
76
|
+
model,
|
|
77
|
+
gpu: gpuOverride || gpu,
|
|
78
|
+
gpu_count: flags.count || 1,
|
|
79
|
+
...(regionOverride || effectiveRegion ? { region: regionOverride || effectiveRegion } : {}),
|
|
80
|
+
max_price_per_hour: flags.maxPrice,
|
|
81
|
+
name: flags.name,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
79
85
|
let dep;
|
|
80
86
|
try {
|
|
81
87
|
dep = await callApi('/serve', {
|
|
82
88
|
method: 'POST',
|
|
83
89
|
apiKey: config.apiKey,
|
|
84
90
|
baseUrl: config.baseUrl,
|
|
85
|
-
body:
|
|
86
|
-
model,
|
|
87
|
-
gpu: gpu.toUpperCase().replace('-', '_'),
|
|
88
|
-
gpu_count: flags.count || 1,
|
|
89
|
-
...(effectiveRegion ? { region: effectiveRegion } : {}),
|
|
90
|
-
max_price_per_hour: flags.maxPrice,
|
|
91
|
-
name: flags.name,
|
|
92
|
-
},
|
|
91
|
+
body: buildBody(),
|
|
93
92
|
});
|
|
94
93
|
} catch (err) {
|
|
95
94
|
const d = err.errorData;
|
|
@@ -100,68 +99,60 @@ export async function serveCommand(config, args, chalk) {
|
|
|
100
99
|
];
|
|
101
100
|
|
|
102
101
|
if (pool.length === 0) {
|
|
103
|
-
console.error(chalk.red(`\n ✗
|
|
104
|
-
console.error(chalk.dim('
|
|
102
|
+
console.error(chalk.red(`\n ✗ No GPU capacity available right now.\n`));
|
|
103
|
+
console.error(chalk.dim(' Run `badgr capacity` to see alternatives, or try again shortly.'));
|
|
105
104
|
process.exit(1);
|
|
106
105
|
}
|
|
107
106
|
|
|
108
|
-
const ranked = rankAlternatives(
|
|
109
|
-
const chosen = await promptFallback(
|
|
107
|
+
const ranked = rankAlternatives(gpuLabel, pool, 'closest');
|
|
108
|
+
const chosen = await promptFallback(gpuLabel, ranked, chalk);
|
|
110
109
|
|
|
111
110
|
if (!chosen) {
|
|
112
111
|
console.log(chalk.dim('\n Cancelled.\n'));
|
|
113
112
|
process.exit(0);
|
|
114
113
|
}
|
|
115
114
|
|
|
116
|
-
console.log(chalk.dim(`\n
|
|
115
|
+
console.log(chalk.dim(`\n Trying ${chosen.gpu} in ${chosen.region}...\n`));
|
|
117
116
|
|
|
118
117
|
try {
|
|
119
118
|
dep = await callApi('/serve', {
|
|
120
119
|
method: 'POST',
|
|
121
120
|
apiKey: config.apiKey,
|
|
122
121
|
baseUrl: config.baseUrl,
|
|
123
|
-
body:
|
|
124
|
-
model,
|
|
125
|
-
gpu: chosen.gpu.toUpperCase().replace('-', '_'),
|
|
126
|
-
gpu_count: flags.count || 1,
|
|
127
|
-
region: chosen.region,
|
|
128
|
-
max_price_per_hour: flags.maxPrice,
|
|
129
|
-
name: flags.name,
|
|
130
|
-
},
|
|
122
|
+
body: buildBody(chosen.gpu, chosen.region),
|
|
131
123
|
});
|
|
132
124
|
} catch (err2) {
|
|
133
125
|
const d2 = err2.errorData;
|
|
134
126
|
if (d2?.code === 'PROVISIONING_FAILED' || d2?.code === 'PROVIDER_ADAPTER_ERROR') {
|
|
135
|
-
console.error(chalk.red(`\n ✗ Badgr found ${chosen.gpu} capacity
|
|
136
|
-
if (d2.message) console.error(chalk.dim(` Detail: ${d2.message}`));
|
|
127
|
+
console.error(chalk.red(`\n ✗ Badgr found ${chosen.gpu} capacity but could not start the endpoint. Please try again.\n`));
|
|
137
128
|
} else {
|
|
138
|
-
console.error(chalk.red(`\n ✗
|
|
129
|
+
console.error(chalk.red(`\n ✗ Failed to start endpoint on ${chosen.gpu}: ${err2.message}\n`));
|
|
139
130
|
}
|
|
140
|
-
console.error(chalk.dim(
|
|
131
|
+
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
|
|
141
132
|
process.exit(1);
|
|
142
133
|
}
|
|
134
|
+
} else if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
|
|
135
|
+
console.error(chalk.red(`\n ✗ Badgr found capacity but could not start the endpoint. Please try again.\n`));
|
|
136
|
+
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
|
|
137
|
+
process.exit(1);
|
|
143
138
|
} else {
|
|
144
|
-
// Write a failure receipt so every serve attempt is auditable.
|
|
145
139
|
const failRcptId = generateReceiptId();
|
|
146
140
|
addReceipt({
|
|
147
141
|
receiptId: failRcptId,
|
|
148
142
|
action: 'badgr serve',
|
|
149
143
|
model,
|
|
150
|
-
gpu,
|
|
144
|
+
gpu: gpuLabel,
|
|
151
145
|
status: 'failed',
|
|
152
146
|
failureType: 'infrastructure',
|
|
153
147
|
createdAt: new Date().toISOString(),
|
|
154
148
|
});
|
|
155
|
-
console.error(chalk.red(`\n ✗
|
|
149
|
+
console.error(chalk.red(`\n ✗ Could not start endpoint: ${err.message}`));
|
|
156
150
|
console.error(chalk.dim(`\n Receipt: ${failRcptId}`));
|
|
157
|
-
console.error(chalk.dim(`
|
|
158
|
-
console.error(chalk.dim(` Config: badgr config`));
|
|
159
|
-
console.error(chalk.dim(` Docs: badgr --help\n`));
|
|
151
|
+
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
|
|
160
152
|
return;
|
|
161
153
|
}
|
|
162
154
|
}
|
|
163
155
|
|
|
164
|
-
// Mirror to local store so badgr down/status/receipts work offline
|
|
165
156
|
addDeployment({
|
|
166
157
|
id: dep.deployment_id,
|
|
167
158
|
name: dep.name,
|
|
@@ -169,7 +160,6 @@ export async function serveCommand(config, args, chalk) {
|
|
|
169
160
|
model: dep.model || model,
|
|
170
161
|
gpu: dep.gpu_type,
|
|
171
162
|
count: dep.gpu_count,
|
|
172
|
-
provider: dep.provider,
|
|
173
163
|
status: dep.status,
|
|
174
164
|
endpointUrl: dep.endpoint_url || dep.openai_base_url,
|
|
175
165
|
receiptId: dep.receipt_id,
|
|
@@ -182,7 +172,6 @@ export async function serveCommand(config, args, chalk) {
|
|
|
182
172
|
receiptId: rcptId,
|
|
183
173
|
action: 'badgr serve',
|
|
184
174
|
deploymentId: dep.deployment_id,
|
|
185
|
-
provider: dep.provider,
|
|
186
175
|
gpu: dep.gpu_type,
|
|
187
176
|
status: dep.status,
|
|
188
177
|
createdAt: new Date().toISOString(),
|
|
@@ -190,48 +179,41 @@ export async function serveCommand(config, args, chalk) {
|
|
|
190
179
|
|
|
191
180
|
const endpointUrl = dep.endpoint_url || dep.openai_base_url || config.baseUrl;
|
|
192
181
|
|
|
193
|
-
// ── Health check
|
|
182
|
+
// ── Health check ──────────────────────────────────────────────────────────
|
|
194
183
|
let endpointReady = false;
|
|
195
|
-
|
|
196
184
|
if (flags.noWait) {
|
|
197
|
-
console.log(chalk.yellow('\n
|
|
185
|
+
console.log(chalk.yellow('\n Skipped health check (--no-wait)\n'));
|
|
198
186
|
} else {
|
|
199
|
-
console.log(chalk.dim('\n Health-checking endpoint (up to 5 min)...'));
|
|
200
187
|
endpointReady = await waitForEndpoint(endpointUrl, 5 * 60 * 1000, chalk);
|
|
201
188
|
process.stdout.write('\n');
|
|
202
|
-
if (!endpointReady) {
|
|
203
|
-
updateReceipt(rcptId, { status: 'health_check_timeout' });
|
|
204
|
-
}
|
|
189
|
+
if (!endpointReady) updateReceipt(rcptId, { status: 'health_check_timeout' });
|
|
205
190
|
}
|
|
206
191
|
|
|
207
|
-
// ──
|
|
192
|
+
// ── Result ────────────────────────────────────────────────────────────────
|
|
208
193
|
if (endpointReady) {
|
|
209
|
-
console.log(chalk.green('✓ Endpoint ready\n'));
|
|
194
|
+
console.log(chalk.green('\n✓ Endpoint ready\n'));
|
|
210
195
|
} else {
|
|
211
|
-
console.log(chalk.yellow('⏳ Endpoint still starting
|
|
212
|
-
console.log(chalk.dim(' The deployment
|
|
213
|
-
console.log(chalk.dim(
|
|
214
|
-
console.log(chalk.dim(`
|
|
215
|
-
console.log(chalk.dim(` badgr logs ${dep.deployment_id}`));
|
|
216
|
-
console.log(chalk.dim(` curl ${endpointUrl}/models`));
|
|
217
|
-
console.log(chalk.dim(` If it never starts, run: badgr down ${dep.deployment_id}`));
|
|
196
|
+
console.log(chalk.yellow('\n⏳ Endpoint still starting\n'));
|
|
197
|
+
console.log(chalk.dim(' The deployment is provisioned but not yet responding.'));
|
|
198
|
+
console.log(chalk.dim(` Check: badgr logs ${dep.deployment_id}`));
|
|
199
|
+
console.log(chalk.dim(` Stop if it never starts: badgr down ${dep.deployment_id}`));
|
|
218
200
|
console.log();
|
|
219
201
|
}
|
|
220
202
|
|
|
221
|
-
console.log(` ${chalk.bold('
|
|
203
|
+
console.log(` ${chalk.bold('Base URL:')} ${chalk.cyan(endpointUrl)}`);
|
|
222
204
|
console.log(` ${chalk.bold('Model:')} ${dep.model || model}`);
|
|
223
205
|
console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
|
|
224
|
-
console.log(` ${chalk.bold('Endpoint:')} ${chalk.cyan(endpointUrl)}`);
|
|
225
206
|
if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
|
|
226
207
|
console.log(` ${chalk.bold('Logs:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
|
|
227
|
-
console.log(
|
|
208
|
+
console.log(` ${chalk.bold('Stop billing:')} ${chalk.dim(`badgr down ${dep.deployment_id}`)}`);
|
|
209
|
+
console.log();
|
|
228
210
|
|
|
229
211
|
if (endpointReady) {
|
|
230
|
-
|
|
212
|
+
const keySnip = config.apiKey?.slice(0, 8) || 'sk-...';
|
|
213
|
+
console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
|
|
231
214
|
console.log(chalk.dim(` from openai import OpenAI`));
|
|
232
|
-
console.log(chalk.dim(` client = OpenAI(
|
|
215
|
+
console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${keySnip}...")`));
|
|
233
216
|
console.log(chalk.dim(` resp = client.chat.completions.create(model="${dep.model || model}", messages=[...])`));
|
|
217
|
+
console.log();
|
|
234
218
|
}
|
|
235
|
-
|
|
236
|
-
console.log(`\n ${chalk.dim(`Stop billing: badgr down ${dep.deployment_id}`)}\n`);
|
|
237
219
|
}
|
package/src/commands/status.js
CHANGED
|
@@ -2,24 +2,20 @@ import { listDeployments as localDeployments } from '../store.js';
|
|
|
2
2
|
import { listDeployments as apiDeployments } from '../api.js';
|
|
3
3
|
|
|
4
4
|
export async function statusCommand(config, args, chalk) {
|
|
5
|
-
console.log(chalk.bold('\n📊 GPU Status\n'));
|
|
6
|
-
|
|
7
5
|
let deployments = [];
|
|
8
6
|
|
|
9
|
-
// ── Live data from the backend ────────────────────────────────────────────
|
|
10
7
|
if (config.apiKey) {
|
|
11
8
|
try {
|
|
12
9
|
const data = await apiDeployments(config);
|
|
13
10
|
deployments = data?.deployments ?? [];
|
|
14
11
|
} catch (err) {
|
|
15
|
-
console.log(chalk.yellow(
|
|
12
|
+
console.log(chalk.yellow(`\n Could not reach API: ${err.message}. Showing local state.\n`));
|
|
16
13
|
deployments = localDeployments().map(d => ({
|
|
17
14
|
deployment_id: d.id,
|
|
18
15
|
name: d.name,
|
|
19
16
|
workload_type: d.type,
|
|
20
17
|
gpu_type: d.gpu,
|
|
21
18
|
gpu_count: d.count,
|
|
22
|
-
provider: d.provider,
|
|
23
19
|
status: d.status,
|
|
24
20
|
cost_per_hour: d.costPerHour,
|
|
25
21
|
endpoint_url: d.endpointUrl,
|
|
@@ -33,7 +29,6 @@ export async function statusCommand(config, args, chalk) {
|
|
|
33
29
|
workload_type: d.type,
|
|
34
30
|
gpu_type: d.gpu,
|
|
35
31
|
gpu_count: d.count,
|
|
36
|
-
provider: d.provider,
|
|
37
32
|
status: d.status,
|
|
38
33
|
cost_per_hour: d.costPerHour,
|
|
39
34
|
endpoint_url: d.endpointUrl,
|
|
@@ -41,54 +36,46 @@ export async function statusCommand(config, args, chalk) {
|
|
|
41
36
|
}));
|
|
42
37
|
}
|
|
43
38
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
const running = deployments.filter(d => d.status === 'running' || d.status === 'provisioning');
|
|
40
|
+
|
|
41
|
+
if (running.length === 0) {
|
|
42
|
+
console.log(chalk.dim('\n Nothing running.\n'));
|
|
43
|
+
console.log(chalk.dim(' badgr run python train.py'));
|
|
44
|
+
console.log(chalk.dim(' badgr serve meta-llama/Llama-3.1-8B-Instruct\n'));
|
|
47
45
|
return;
|
|
48
46
|
}
|
|
49
47
|
|
|
50
|
-
|
|
48
|
+
console.log(chalk.bold('\nRunning now:\n'));
|
|
49
|
+
for (const d of running) {
|
|
50
|
+
const id = d.name || d.deployment_id;
|
|
51
|
+
const type = d.workload_type === 'endpoint' ? 'endpoint' : 'job';
|
|
52
|
+
const gpu = d.gpu_type || '—';
|
|
53
|
+
const rate = d.cost_per_hour > 0 ? chalk.yellow(`$${d.cost_per_hour.toFixed(2)}/hr`) : '';
|
|
54
|
+
const badge = d.status === 'provisioning'
|
|
55
|
+
? chalk.yellow('● starting')
|
|
56
|
+
: chalk.green('● running');
|
|
51
57
|
|
|
52
|
-
|
|
53
|
-
'Name'.padEnd(cols.name) +
|
|
54
|
-
'Type'.padEnd(cols.type) +
|
|
55
|
-
'GPU'.padEnd(cols.gpu) +
|
|
56
|
-
'Status'.padEnd(cols.status) +
|
|
57
|
-
'Price/hr';
|
|
58
|
-
console.log(' ' + chalk.bold(header));
|
|
59
|
-
console.log(' ' + '─'.repeat(Object.values(cols).reduce((a, b) => a + b, 0) + 8));
|
|
58
|
+
console.log(` ${badge} ${chalk.bold(id)} ${type} ${gpu} ${rate}`);
|
|
60
59
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const gpu = (d.gpu_type || '—').slice(0, cols.gpu - 1).padEnd(cols.gpu);
|
|
70
|
-
console.log(
|
|
71
|
-
' ' +
|
|
72
|
-
name.padEnd(cols.name) +
|
|
73
|
-
(d.workload_type || '—').padEnd(cols.type) +
|
|
74
|
-
gpu +
|
|
75
|
-
statusColor +
|
|
76
|
-
price
|
|
77
|
-
);
|
|
78
|
-
});
|
|
60
|
+
if (d.workload_type === 'endpoint' && d.endpoint_url) {
|
|
61
|
+
console.log(` ${chalk.dim('URL:')} ${d.endpoint_url}`);
|
|
62
|
+
}
|
|
63
|
+
if (d.model) {
|
|
64
|
+
console.log(` ${chalk.dim('Model:')} ${d.model}`);
|
|
65
|
+
}
|
|
66
|
+
console.log();
|
|
67
|
+
}
|
|
79
68
|
|
|
80
|
-
|
|
69
|
+
const billable = running.filter(d => (d.cost_per_hour || 0) > 0);
|
|
70
|
+
if (billable.length > 0) {
|
|
71
|
+
const totalPerHr = billable.reduce((s, d) => s + (d.cost_per_hour || 0), 0);
|
|
72
|
+
console.log(` ${chalk.bold('Total billing:')} ${chalk.yellow(`$${totalPerHr.toFixed(2)}/hr`)}\n`);
|
|
73
|
+
}
|
|
81
74
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
console.log(chalk.
|
|
86
|
-
endpoints.forEach(d => {
|
|
87
|
-
const url = d.endpoint_url || config.baseUrl;
|
|
88
|
-
console.log(` ${chalk.cyan(d.name || d.deployment_id)}`);
|
|
89
|
-
console.log(` ${chalk.dim('URL:')} ${url}`);
|
|
90
|
-
if (d.model) console.log(` ${chalk.dim('model:')} ${d.model}`);
|
|
91
|
-
});
|
|
92
|
-
console.log();
|
|
75
|
+
console.log(chalk.bold('Stop billing:\n'));
|
|
76
|
+
for (const d of running) {
|
|
77
|
+
const id = d.name || d.deployment_id;
|
|
78
|
+
console.log(` ${chalk.cyan(`badgr down ${id}`)}`);
|
|
93
79
|
}
|
|
80
|
+
console.log();
|
|
94
81
|
}
|