badgr-cli 1.0.11 → 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 +127 -73
- package/src/commands/serve.js +61 -73
- package/src/commands/status.js +35 -48
- package/src/fallback.js +2 -1
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', {
|
|
@@ -257,15 +306,23 @@ export async function runCommand(config, args, chalk) {
|
|
|
257
306
|
body: buildBody(chosen.gpu, chosen.region),
|
|
258
307
|
});
|
|
259
308
|
} catch (err2) {
|
|
260
|
-
|
|
261
|
-
|
|
309
|
+
const d2 = err2.errorData;
|
|
310
|
+
if (d2?.code === 'PROVISIONING_FAILED' || d2?.code === 'PROVIDER_ADAPTER_ERROR') {
|
|
311
|
+
console.error(chalk.red(`\n ✗ Badgr found ${chosen.gpu} capacity but could not start the machine. Please try again.\n`));
|
|
312
|
+
} else {
|
|
313
|
+
console.error(chalk.red(`\n ✗ Job failed to start on ${chosen.gpu}: ${err2.message}\n`));
|
|
314
|
+
}
|
|
315
|
+
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.\n`));
|
|
262
316
|
process.exit(1);
|
|
263
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);
|
|
264
322
|
} else {
|
|
265
|
-
console.error(chalk.red(`\n ✗
|
|
266
|
-
console.error(chalk.dim(
|
|
267
|
-
console.error(chalk.dim(`
|
|
268
|
-
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`));
|
|
269
326
|
process.exit(1);
|
|
270
327
|
}
|
|
271
328
|
}
|
|
@@ -275,13 +332,13 @@ export async function runCommand(config, args, chalk) {
|
|
|
275
332
|
receiptId: rcptId,
|
|
276
333
|
action: 'badgr run',
|
|
277
334
|
deploymentId: dep.deployment_id,
|
|
278
|
-
provider: dep.provider,
|
|
279
335
|
gpu: dep.gpu_type,
|
|
280
336
|
status: dep.status,
|
|
281
337
|
createdAt: new Date().toISOString(),
|
|
282
338
|
});
|
|
283
339
|
|
|
284
|
-
console.log(
|
|
340
|
+
console.log();
|
|
341
|
+
console.log(chalk.bold(` Job ID: ${chalk.cyan(dep.deployment_id)}`));
|
|
285
342
|
console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
|
|
286
343
|
if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
|
|
287
344
|
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
@@ -292,16 +349,14 @@ export async function runCommand(config, args, chalk) {
|
|
|
292
349
|
return;
|
|
293
350
|
}
|
|
294
351
|
|
|
295
|
-
// ── Attached mode: stream logs until job completes ────────────────────────
|
|
296
352
|
const ratePerHour = dep.cost_per_hour || 0;
|
|
297
|
-
console.log(chalk.dim('\n ──
|
|
353
|
+
console.log(chalk.dim('\n ── Streaming logs (Ctrl+C to stop job) ─────────────────────────\n'));
|
|
298
354
|
|
|
299
|
-
// Teardown helper — called on SIGINT, max-runtime, max-cost, or heartbeat loss.
|
|
300
355
|
async function teardown(reason) {
|
|
301
356
|
const labels = {
|
|
302
357
|
'max-runtime': chalk.yellow('\n ⏱ Max runtime reached — stopping job...'),
|
|
303
358
|
'max-cost': chalk.yellow('\n 💰 Spend cap reached — stopping job...'),
|
|
304
|
-
'heartbeat-lost': chalk.red('\n ✗
|
|
359
|
+
'heartbeat-lost': chalk.red('\n ✗ No response from machine — stopping job...'),
|
|
305
360
|
'interrupted': chalk.yellow('\n Stopping job...'),
|
|
306
361
|
};
|
|
307
362
|
console.log(labels[reason] ?? chalk.yellow('\n Stopping job...'));
|
|
@@ -312,8 +367,7 @@ export async function runCommand(config, args, chalk) {
|
|
|
312
367
|
}
|
|
313
368
|
const runtimeMs = Date.now() - attachStart;
|
|
314
369
|
const finalCost = ratePerHour * (runtimeMs / 3_600_000);
|
|
315
|
-
|
|
316
|
-
updateReceipt(rcptId, { status: reason, runtimeSeconds: Math.round(runtimeMs / 1000), finalCost, failureType });
|
|
370
|
+
updateReceipt(rcptId, { status: reason, runtimeSeconds: Math.round(runtimeMs / 1000), finalCost });
|
|
317
371
|
console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Est. cost: $${finalCost.toFixed(4)}`));
|
|
318
372
|
console.log(chalk.dim(' Job stopped. Billing ended.\n'));
|
|
319
373
|
process.exit(reason === 'interrupted' ? 0 : 1);
|
|
@@ -339,7 +393,6 @@ export async function runCommand(config, args, chalk) {
|
|
|
339
393
|
failureType,
|
|
340
394
|
});
|
|
341
395
|
|
|
342
|
-
// ── Job summary ──────────────────────────────────────────────────────────
|
|
343
396
|
console.log(` ${chalk.bold('Runtime:')} ${fmtRuntime(runtimeMs)}`);
|
|
344
397
|
if (ratePerHour > 0) {
|
|
345
398
|
console.log(` ${chalk.bold('Cost:')} $${finalCost.toFixed(4)} (${chalk.dim(`$${ratePerHour.toFixed(2)}/hr`)})`);
|
|
@@ -347,18 +400,19 @@ export async function runCommand(config, args, chalk) {
|
|
|
347
400
|
if (exitCode !== null && exitCode !== undefined) {
|
|
348
401
|
console.log(` ${chalk.bold('Exit code:')} ${exitCode !== 0 ? chalk.red(exitCode) : chalk.green(exitCode)}`);
|
|
349
402
|
}
|
|
403
|
+
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
350
404
|
|
|
351
405
|
if (finalStatus === 'failed' || (exitCode !== null && exitCode !== 0)) {
|
|
352
|
-
const label = failureType === 'infrastructure'
|
|
353
|
-
? chalk.red(`\n ✗ Infrastructure failure (${dep.deployment_id}) — not your code\n`)
|
|
354
|
-
: chalk.red(`\n ✗ Job failed (${dep.deployment_id})\n`);
|
|
355
|
-
console.error(label);
|
|
356
406
|
if (failureType === 'infrastructure') {
|
|
357
|
-
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}`));
|
|
358
412
|
}
|
|
359
|
-
console.
|
|
413
|
+
console.log();
|
|
360
414
|
process.exit(exitCode ?? 1);
|
|
361
415
|
} else {
|
|
362
|
-
console.log(chalk.green(`\n ✓
|
|
416
|
+
console.log(chalk.green(`\n ✓ Complete\n`));
|
|
363
417
|
}
|
|
364
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,62 +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
|
-
|
|
134
|
-
|
|
125
|
+
const d2 = err2.errorData;
|
|
126
|
+
if (d2?.code === 'PROVISIONING_FAILED' || d2?.code === 'PROVIDER_ADAPTER_ERROR') {
|
|
127
|
+
console.error(chalk.red(`\n ✗ Badgr found ${chosen.gpu} capacity but could not start the endpoint. Please try again.\n`));
|
|
128
|
+
} else {
|
|
129
|
+
console.error(chalk.red(`\n ✗ Failed to start endpoint on ${chosen.gpu}: ${err2.message}\n`));
|
|
130
|
+
}
|
|
131
|
+
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
|
|
135
132
|
process.exit(1);
|
|
136
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);
|
|
137
138
|
} else {
|
|
138
|
-
// Write a failure receipt so every serve attempt is auditable.
|
|
139
139
|
const failRcptId = generateReceiptId();
|
|
140
140
|
addReceipt({
|
|
141
141
|
receiptId: failRcptId,
|
|
142
142
|
action: 'badgr serve',
|
|
143
143
|
model,
|
|
144
|
-
gpu,
|
|
144
|
+
gpu: gpuLabel,
|
|
145
145
|
status: 'failed',
|
|
146
146
|
failureType: 'infrastructure',
|
|
147
147
|
createdAt: new Date().toISOString(),
|
|
148
148
|
});
|
|
149
|
-
console.error(chalk.red(`\n ✗
|
|
149
|
+
console.error(chalk.red(`\n ✗ Could not start endpoint: ${err.message}`));
|
|
150
150
|
console.error(chalk.dim(`\n Receipt: ${failRcptId}`));
|
|
151
|
-
console.error(chalk.dim(`
|
|
152
|
-
console.error(chalk.dim(` Config: badgr config`));
|
|
153
|
-
console.error(chalk.dim(` Docs: badgr --help\n`));
|
|
151
|
+
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
|
|
154
152
|
return;
|
|
155
153
|
}
|
|
156
154
|
}
|
|
157
155
|
|
|
158
|
-
// Mirror to local store so badgr down/status/receipts work offline
|
|
159
156
|
addDeployment({
|
|
160
157
|
id: dep.deployment_id,
|
|
161
158
|
name: dep.name,
|
|
@@ -163,7 +160,6 @@ export async function serveCommand(config, args, chalk) {
|
|
|
163
160
|
model: dep.model || model,
|
|
164
161
|
gpu: dep.gpu_type,
|
|
165
162
|
count: dep.gpu_count,
|
|
166
|
-
provider: dep.provider,
|
|
167
163
|
status: dep.status,
|
|
168
164
|
endpointUrl: dep.endpoint_url || dep.openai_base_url,
|
|
169
165
|
receiptId: dep.receipt_id,
|
|
@@ -176,7 +172,6 @@ export async function serveCommand(config, args, chalk) {
|
|
|
176
172
|
receiptId: rcptId,
|
|
177
173
|
action: 'badgr serve',
|
|
178
174
|
deploymentId: dep.deployment_id,
|
|
179
|
-
provider: dep.provider,
|
|
180
175
|
gpu: dep.gpu_type,
|
|
181
176
|
status: dep.status,
|
|
182
177
|
createdAt: new Date().toISOString(),
|
|
@@ -184,48 +179,41 @@ export async function serveCommand(config, args, chalk) {
|
|
|
184
179
|
|
|
185
180
|
const endpointUrl = dep.endpoint_url || dep.openai_base_url || config.baseUrl;
|
|
186
181
|
|
|
187
|
-
// ── Health check
|
|
182
|
+
// ── Health check ──────────────────────────────────────────────────────────
|
|
188
183
|
let endpointReady = false;
|
|
189
|
-
|
|
190
184
|
if (flags.noWait) {
|
|
191
|
-
console.log(chalk.yellow('\n
|
|
185
|
+
console.log(chalk.yellow('\n Skipped health check (--no-wait)\n'));
|
|
192
186
|
} else {
|
|
193
|
-
console.log(chalk.dim('\n Health-checking endpoint (up to 5 min)...'));
|
|
194
187
|
endpointReady = await waitForEndpoint(endpointUrl, 5 * 60 * 1000, chalk);
|
|
195
188
|
process.stdout.write('\n');
|
|
196
|
-
if (!endpointReady) {
|
|
197
|
-
updateReceipt(rcptId, { status: 'health_check_timeout' });
|
|
198
|
-
}
|
|
189
|
+
if (!endpointReady) updateReceipt(rcptId, { status: 'health_check_timeout' });
|
|
199
190
|
}
|
|
200
191
|
|
|
201
|
-
// ──
|
|
192
|
+
// ── Result ────────────────────────────────────────────────────────────────
|
|
202
193
|
if (endpointReady) {
|
|
203
|
-
console.log(chalk.green('✓ Endpoint ready\n'));
|
|
194
|
+
console.log(chalk.green('\n✓ Endpoint ready\n'));
|
|
204
195
|
} else {
|
|
205
|
-
console.log(chalk.yellow('⏳ Endpoint still starting
|
|
206
|
-
console.log(chalk.dim(' The deployment
|
|
207
|
-
console.log(chalk.dim(
|
|
208
|
-
console.log(chalk.dim(`
|
|
209
|
-
console.log(chalk.dim(` badgr logs ${dep.deployment_id}`));
|
|
210
|
-
console.log(chalk.dim(` curl ${endpointUrl}/models`));
|
|
211
|
-
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}`));
|
|
212
200
|
console.log();
|
|
213
201
|
}
|
|
214
202
|
|
|
215
|
-
console.log(` ${chalk.bold('
|
|
203
|
+
console.log(` ${chalk.bold('Base URL:')} ${chalk.cyan(endpointUrl)}`);
|
|
216
204
|
console.log(` ${chalk.bold('Model:')} ${dep.model || model}`);
|
|
217
205
|
console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
|
|
218
|
-
console.log(` ${chalk.bold('Endpoint:')} ${chalk.cyan(endpointUrl)}`);
|
|
219
206
|
if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
|
|
220
207
|
console.log(` ${chalk.bold('Logs:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
|
|
221
|
-
console.log(
|
|
208
|
+
console.log(` ${chalk.bold('Stop billing:')} ${chalk.dim(`badgr down ${dep.deployment_id}`)}`);
|
|
209
|
+
console.log();
|
|
222
210
|
|
|
223
211
|
if (endpointReady) {
|
|
224
|
-
|
|
212
|
+
const keySnip = config.apiKey?.slice(0, 8) || 'sk-...';
|
|
213
|
+
console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
|
|
225
214
|
console.log(chalk.dim(` from openai import OpenAI`));
|
|
226
|
-
console.log(chalk.dim(` client = OpenAI(
|
|
215
|
+
console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${keySnip}...")`));
|
|
227
216
|
console.log(chalk.dim(` resp = client.chat.completions.create(model="${dep.model || model}", messages=[...])`));
|
|
217
|
+
console.log();
|
|
228
218
|
}
|
|
229
|
-
|
|
230
|
-
console.log(`\n ${chalk.dim(`Stop billing: badgr down ${dep.deployment_id}`)}\n`);
|
|
231
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
|
}
|
package/src/fallback.js
CHANGED
|
@@ -89,8 +89,9 @@ export async function promptFallback(requestedGpu, ranked, chalk) {
|
|
|
89
89
|
console.log(chalk.yellow(`\n ${requestedGpu} isn't available right now.\n`));
|
|
90
90
|
console.log(chalk.bold(' Closest match:'));
|
|
91
91
|
console.log(` ${chalk.cyan(top.gpu)} in ${top.region}`);
|
|
92
|
-
console.log(` ${chalk.green('$' + top.price.toFixed(2) + '/hr')} estimated`);
|
|
92
|
+
console.log(` ${chalk.green('$' + top.price.toFixed(2) + '/hr')} estimated price`);
|
|
93
93
|
if (topMeta?.desc) console.log(` ${topMeta.desc}`);
|
|
94
|
+
console.log(chalk.dim(' (availability estimated from market data — not pre-verified)'));
|
|
94
95
|
console.log();
|
|
95
96
|
|
|
96
97
|
if (!process.stdin.isTTY) {
|