badgr-cli 1.0.7 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/badgr.js +17 -6
- package/src/commands/capacity.js +104 -0
- package/src/commands/receipts.js +19 -4
- package/src/commands/run.js +257 -70
- package/src/commands/serve.js +79 -11
- package/src/fallback.js +123 -0
- package/src/store.js +9 -0
- package/tests/commands.test.js +132 -2
- package/tests/store.test.js +41 -1
package/package.json
CHANGED
package/src/badgr.js
CHANGED
|
@@ -10,6 +10,7 @@ import { receiptsCommand } from './commands/receipts.js';
|
|
|
10
10
|
import { runCommand } from './commands/run.js';
|
|
11
11
|
import { serveCommand } from './commands/serve.js';
|
|
12
12
|
import { modelsCommand } from './commands/models.js';
|
|
13
|
+
import { capacityCommand } from './commands/capacity.js';
|
|
13
14
|
|
|
14
15
|
const HELP = `
|
|
15
16
|
${chalk.bold('badgr')} — run or serve GPU workloads from one command
|
|
@@ -22,14 +23,20 @@ ${chalk.bold('CORE COMMANDS')}
|
|
|
22
23
|
${chalk.cyan('badgr logs <id>')} Stream logs for a deployment
|
|
23
24
|
${chalk.cyan('badgr down <id>')} Terminate a deployment (stop billing)
|
|
24
25
|
${chalk.cyan('badgr receipts [<id>|<n>]')} Show receipts — pass ID for single, number for list
|
|
26
|
+
${chalk.cyan('badgr capacity [--gpu <type>]')} Check live GPU availability and alternatives
|
|
25
27
|
|
|
26
28
|
${chalk.bold('badgr run OPTIONS')}
|
|
27
|
-
--gpu <type>
|
|
28
|
-
--image <image>
|
|
29
|
-
--count <n>
|
|
30
|
-
--region US|EU|AU
|
|
31
|
-
--max-price <$/hr>
|
|
32
|
-
--name <name>
|
|
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
|
|
33
40
|
|
|
34
41
|
${chalk.bold('badgr serve OPTIONS')}
|
|
35
42
|
--gpu <type> GPU: L40S, A100, H100, RTX_4090 (default: L40S)
|
|
@@ -41,6 +48,9 @@ ${chalk.bold('badgr serve OPTIONS')}
|
|
|
41
48
|
${chalk.bold('EXAMPLES')}
|
|
42
49
|
badgr login
|
|
43
50
|
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
|
|
44
54
|
badgr run --image my/image:latest --gpu L40S
|
|
45
55
|
badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S
|
|
46
56
|
badgr serve mistralai/Mistral-7B-v0.1 --gpu RTX_4090
|
|
@@ -83,6 +93,7 @@ async function main() {
|
|
|
83
93
|
case 'down': return downCommand(config, rest, chalk);
|
|
84
94
|
case 'receipts': return receiptsCommand(config, rest, chalk);
|
|
85
95
|
case 'models': return modelsCommand(config, chalk);
|
|
96
|
+
case 'capacity': return capacityCommand(config, rest, chalk);
|
|
86
97
|
// legacy aliases kept for compatibility
|
|
87
98
|
case 'up': return upCommand(config, rest, chalk);
|
|
88
99
|
case 'config': {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { requireApiKey } from '../config.js';
|
|
2
|
+
import { callApi } from '../api.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* badgr capacity # check RTX_4090 at default $10/hr cap
|
|
6
|
+
* badgr capacity --gpu L40S # check a specific GPU type
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function parseCapacityArgs(args) {
|
|
15
|
+
const flags = {};
|
|
16
|
+
let i = 0;
|
|
17
|
+
while (i < args.length) {
|
|
18
|
+
if (args[i] === '--gpu') { flags.gpu = args[++i]; i++; continue; }
|
|
19
|
+
if (args[i] === '--region') { flags.region = args[++i]; i++; continue; }
|
|
20
|
+
if (args[i] === '--max-price') { flags.maxPrice = parseFloat(args[++i]); i++; continue; }
|
|
21
|
+
i++;
|
|
22
|
+
}
|
|
23
|
+
return flags;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function capacityCommand(config, args, chalk) {
|
|
27
|
+
const flags = parseCapacityArgs(args);
|
|
28
|
+
requireApiKey(config);
|
|
29
|
+
|
|
30
|
+
const gpu = flags.gpu || 'RTX_4090';
|
|
31
|
+
const maxPrice = flags.maxPrice ?? 10;
|
|
32
|
+
|
|
33
|
+
const params = new URLSearchParams({ gpu, max_price: String(maxPrice) });
|
|
34
|
+
if (flags.region) params.set('region', flags.region.toUpperCase());
|
|
35
|
+
|
|
36
|
+
console.log(chalk.bold(`\n⚡ GPU Capacity Check\n`));
|
|
37
|
+
console.log(` ${chalk.bold('GPU:')} ${gpu}`);
|
|
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();
|
|
41
|
+
|
|
42
|
+
let data;
|
|
43
|
+
try {
|
|
44
|
+
data = await callApi(`/capacity/suggestions?${params}`, {
|
|
45
|
+
apiKey: config.apiKey,
|
|
46
|
+
baseUrl: config.baseUrl,
|
|
47
|
+
});
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error(chalk.red(`\n ✗ Capacity check failed: ${err.message}\n`));
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
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
|
+
const matches = data.matches ?? [];
|
|
76
|
+
if (matches.length > 0) {
|
|
77
|
+
console.log(chalk.bold(' Available now:'));
|
|
78
|
+
for (const m of matches) {
|
|
79
|
+
console.log(` ${chalk.green('•')} ${m.gpu} in ${m.region} $${m.price.toFixed(2)}/hr`);
|
|
80
|
+
}
|
|
81
|
+
console.log();
|
|
82
|
+
console.log(chalk.bold(' Run:'));
|
|
83
|
+
console.log(chalk.cyan(` badgr run ... --gpu ${gpu}${flags.region ? ' --region ' + flags.region.toUpperCase() : ''}`));
|
|
84
|
+
} else {
|
|
85
|
+
console.log(chalk.dim(` No ${gpu} found under $${maxPrice.toFixed(2)}/hr${flags.region ? ' in ' + flags.region.toUpperCase() : ' globally'}.`));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Alternatives
|
|
89
|
+
const alternatives = data.alternatives ?? [];
|
|
90
|
+
if (alternatives.length > 0 && matches.length === 0) {
|
|
91
|
+
console.log();
|
|
92
|
+
console.log(chalk.bold(' Alternatives:'));
|
|
93
|
+
for (const a of alternatives) {
|
|
94
|
+
console.log(` ${chalk.dim('•')} ${a.gpu} in ${a.region} $${a.price.toFixed(2)}/hr`);
|
|
95
|
+
}
|
|
96
|
+
console.log();
|
|
97
|
+
console.log(chalk.bold(' Try:'));
|
|
98
|
+
for (const a of alternatives) {
|
|
99
|
+
console.log(chalk.cyan(` badgr run ... --gpu ${a.gpu}`));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log();
|
|
104
|
+
}
|
package/src/commands/receipts.js
CHANGED
|
@@ -5,19 +5,34 @@ function fmtMs(ms) {
|
|
|
5
5
|
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
function fmtRuntime(s) {
|
|
9
|
+
if (s < 60) return `${s}s`;
|
|
10
|
+
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
function printReceipt(r, chalk) {
|
|
9
14
|
const id = r.receiptId ?? r.request_id ?? r.id ?? '—';
|
|
10
15
|
const ts = r.createdAt ?? r.created_at ?? '—';
|
|
11
|
-
const cost = r.route?.ratePerHour ?? r.cost_usd ?? 0;
|
|
12
|
-
const prov = r.route?.provider ?? r.provider ?? r.model_provider ?? '—';
|
|
13
|
-
const lat = r.latencyMs ?? r.latency_ms;
|
|
14
16
|
const action = r.action ?? r.endpoint ?? '—';
|
|
17
|
+
const lat = r.latencyMs ?? r.latency_ms;
|
|
15
18
|
|
|
16
19
|
console.log(` ${chalk.cyan(id)}`);
|
|
17
20
|
console.log(` ${chalk.bold('action:')} ${action}`);
|
|
21
|
+
|
|
22
|
+
// GPU job fields (from badgr run / badgr down)
|
|
23
|
+
if (r.gpu) console.log(` ${chalk.bold('gpu:')} ${r.gpu}`);
|
|
24
|
+
if (r.runtimeSeconds) console.log(` ${chalk.bold('runtime:')} ${fmtRuntime(r.runtimeSeconds)}`);
|
|
25
|
+
if (r.finalCost) console.log(` ${chalk.bold('cost:')} $${r.finalCost.toFixed(4)}`);
|
|
26
|
+
else if (r.cost_usd) console.log(` ${chalk.bold('cost:')} $${r.cost_usd.toFixed(4)}`);
|
|
27
|
+
if (r.exitCode !== undefined && r.exitCode !== null) {
|
|
28
|
+
console.log(` ${chalk.bold('exit:')} ${r.exitCode !== 0 ? chalk.red(r.exitCode) : chalk.green(r.exitCode)}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// API inference receipt fields
|
|
18
32
|
if (lat !== undefined) console.log(` ${chalk.bold('latency:')} ${fmtMs(lat)}`);
|
|
19
|
-
if (cost) console.log(` ${chalk.bold('cost:')} $${typeof cost === 'number' ? cost.toFixed(4) : cost}/hr`);
|
|
20
33
|
if (r.retries !== undefined) console.log(` ${chalk.bold('retries:')} ${r.retries}`);
|
|
34
|
+
if (r.route?.ratePerHour) console.log(` ${chalk.bold('rate:')} $${r.route.ratePerHour.toFixed(4)}/hr`);
|
|
35
|
+
|
|
21
36
|
if (r.status) console.log(` ${chalk.bold('status:')} ${r.status}`);
|
|
22
37
|
console.log(` ${chalk.dim(ts)}`);
|
|
23
38
|
console.log();
|
package/src/commands/run.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { requireApiKey } from '../config.js';
|
|
2
|
-
import { callApi } from '../api.js';
|
|
3
|
-
import { addReceipt, generateReceiptId } from '../store.js';
|
|
2
|
+
import { callApi, terminateDeployment } from '../api.js';
|
|
3
|
+
import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
|
|
4
|
+
import { rankAlternatives, promptFallback } from '../fallback.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* badgr run python train.py --gpu A100 # attached (default)
|
|
@@ -20,60 +21,137 @@ export function parseRunArgs(args) {
|
|
|
20
21
|
if (args[i] === '--region') { flags.region = args[++i]; i++; continue; }
|
|
21
22
|
if (args[i] === '--max-price') { flags.maxPrice = parseFloat(args[++i]); i++; continue; }
|
|
22
23
|
if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
|
|
23
|
-
if (args[i] === '--detach')
|
|
24
|
+
if (args[i] === '--detach') { flags.detach = true; i++; continue; }
|
|
25
|
+
if (args[i] === '--fallback') { flags.fallback = args[++i]; i++; continue; }
|
|
26
|
+
if (args[i] === '--no-fallback') { flags.noFallback = true; i++; continue; }
|
|
27
|
+
if (args[i] === '--max-runtime') { flags.maxRuntime = parseFloat(args[++i]); i++; continue; }
|
|
28
|
+
if (args[i] === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
|
|
24
29
|
positional.push(args[i++]);
|
|
25
30
|
}
|
|
26
31
|
return { flags, positional };
|
|
27
32
|
}
|
|
28
33
|
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
function fmtRuntime(ms) {
|
|
35
|
+
const s = Math.round(ms / 1000);
|
|
36
|
+
if (s < 60) return `${s}s`;
|
|
37
|
+
const m = Math.floor(s / 60);
|
|
38
|
+
return `${m}m ${s % 60}s`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Polls above this count without a response while 'running' → heartbeat lost.
|
|
42
|
+
const HEARTBEAT_WARN_POLLS = 3; // ~12s → warn
|
|
43
|
+
const HEARTBEAT_KILL_POLLS = 15; // ~60s → treat as infrastructure failure
|
|
44
|
+
|
|
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
|
+
export function classifyFailure(finalStatus, exitCode) {
|
|
51
|
+
if (finalStatus === 'failed' && (exitCode === null || exitCode === undefined)) return 'infrastructure';
|
|
52
|
+
if (exitCode !== null && exitCode !== undefined && exitCode !== 0) return 'customer_code';
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
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
|
+
async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost = null, ratePerHour = 0, onTeardown }) {
|
|
31
62
|
const TERMINAL = new Set(['stopped', 'failed', 'completed']);
|
|
32
63
|
const POLL_MS = 4000;
|
|
33
|
-
let seenLines
|
|
34
|
-
let lastStatus
|
|
64
|
+
let seenLines = 0;
|
|
65
|
+
let lastStatus = '';
|
|
66
|
+
let consecutiveErrs = 0;
|
|
67
|
+
const startMs = Date.now();
|
|
35
68
|
|
|
36
|
-
|
|
37
|
-
|
|
69
|
+
let tearing = false;
|
|
70
|
+
const sigintHandler = () => {
|
|
71
|
+
if (tearing) return;
|
|
72
|
+
tearing = true;
|
|
73
|
+
onTeardown('interrupted');
|
|
74
|
+
};
|
|
75
|
+
process.once('SIGINT', sigintHandler);
|
|
38
76
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
});
|
|
46
|
-
} catch {
|
|
47
|
-
// transient network error — keep trying
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
77
|
+
try {
|
|
78
|
+
while (true) {
|
|
79
|
+
await new Promise(r => setTimeout(r, POLL_MS));
|
|
80
|
+
|
|
81
|
+
const elapsedMs = Date.now() - startMs;
|
|
82
|
+
const spentSoFar = ratePerHour * (elapsedMs / 3_600_000);
|
|
50
83
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
84
|
+
// Hard spend cap
|
|
85
|
+
if (maxCost !== null && spentSoFar >= maxCost) {
|
|
86
|
+
tearing = true;
|
|
87
|
+
onTeardown('max-cost');
|
|
88
|
+
return { status: 'timeout', exitCode: null, runtimeMs: elapsedMs, failureType: null };
|
|
55
89
|
}
|
|
56
|
-
lastStatus = status;
|
|
57
|
-
}
|
|
58
90
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
});
|
|
65
|
-
const lines = logData?.logs ?? [];
|
|
66
|
-
for (let i = seenLines; i < lines.length; i++) {
|
|
67
|
-
console.log(` ${chalk.dim(lines[i])}`);
|
|
91
|
+
// Hard runtime cap
|
|
92
|
+
if (maxRuntimeMs !== null && elapsedMs >= maxRuntimeMs) {
|
|
93
|
+
tearing = true;
|
|
94
|
+
onTeardown('max-runtime');
|
|
95
|
+
return { status: 'timeout', exitCode: null, runtimeMs: elapsedMs, failureType: null };
|
|
68
96
|
}
|
|
69
|
-
seenLines = lines.length;
|
|
70
|
-
} catch {
|
|
71
|
-
// logs not ready yet
|
|
72
|
-
}
|
|
73
97
|
|
|
74
|
-
|
|
75
|
-
|
|
98
|
+
let dep;
|
|
99
|
+
try {
|
|
100
|
+
dep = await callApi(`/deployments/${depId}`, {
|
|
101
|
+
apiKey: config.apiKey,
|
|
102
|
+
baseUrl: config.baseUrl,
|
|
103
|
+
});
|
|
104
|
+
consecutiveErrs = 0;
|
|
105
|
+
} catch {
|
|
106
|
+
consecutiveErrs++;
|
|
107
|
+
if (lastStatus === 'running') {
|
|
108
|
+
const lostSec = Math.round((consecutiveErrs * POLL_MS) / 1000);
|
|
109
|
+
if (consecutiveErrs === HEARTBEAT_WARN_POLLS) {
|
|
110
|
+
console.log(chalk.yellow(`\n ⚠ No heartbeat for ${lostSec}s — Vast machine may be unresponsive`));
|
|
111
|
+
} else if (consecutiveErrs >= HEARTBEAT_KILL_POLLS) {
|
|
112
|
+
tearing = true;
|
|
113
|
+
onTeardown('heartbeat-lost');
|
|
114
|
+
return { status: 'failed', exitCode: null, runtimeMs: elapsedMs, failureType: 'infrastructure' };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const status = dep.status;
|
|
121
|
+
if (status !== lastStatus) {
|
|
122
|
+
if (status === 'running' && lastStatus === 'provisioning') {
|
|
123
|
+
console.log(chalk.dim(' [running]'));
|
|
124
|
+
}
|
|
125
|
+
lastStatus = status;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const logData = await callApi(`/deployments/${depId}/logs`, {
|
|
130
|
+
apiKey: config.apiKey,
|
|
131
|
+
baseUrl: config.baseUrl,
|
|
132
|
+
});
|
|
133
|
+
const lines = logData?.logs ?? [];
|
|
134
|
+
for (let i = seenLines; i < lines.length; i++) {
|
|
135
|
+
console.log(` ${chalk.dim(lines[i])}`);
|
|
136
|
+
}
|
|
137
|
+
seenLines = lines.length;
|
|
138
|
+
} catch {
|
|
139
|
+
// logs not ready yet
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (TERMINAL.has(status)) {
|
|
143
|
+
process.removeListener('SIGINT', sigintHandler);
|
|
144
|
+
const exitCode = dep.exit_code ?? null;
|
|
145
|
+
return {
|
|
146
|
+
status,
|
|
147
|
+
exitCode,
|
|
148
|
+
runtimeMs: Date.now() - startMs,
|
|
149
|
+
failureType: classifyFailure(status, exitCode),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
76
152
|
}
|
|
153
|
+
} finally {
|
|
154
|
+
process.removeListener('SIGINT', sigintHandler);
|
|
77
155
|
}
|
|
78
156
|
}
|
|
79
157
|
|
|
@@ -88,19 +166,49 @@ export async function runCommand(config, args, chalk) {
|
|
|
88
166
|
|
|
89
167
|
requireApiKey(config);
|
|
90
168
|
|
|
91
|
-
const command
|
|
92
|
-
const gpu
|
|
93
|
-
const image
|
|
94
|
-
const detach
|
|
169
|
+
const command = positional.length > 0 ? positional : undefined;
|
|
170
|
+
const gpu = flags.gpu || 'RTX_4090';
|
|
171
|
+
const image = flags.image || (command ? 'python:3.11-slim' : undefined);
|
|
172
|
+
const detach = flags.detach || false;
|
|
173
|
+
const fallbackMode = flags.noFallback ? 'none' : (flags.fallback || 'closest');
|
|
174
|
+
const maxRuntimeMs = flags.maxRuntime ? flags.maxRuntime * 60 * 1000 : null;
|
|
175
|
+
const maxCost = flags.maxCost ?? null;
|
|
95
176
|
|
|
96
177
|
console.log(chalk.bold('\n⚡ Running GPU job\n'));
|
|
97
178
|
if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
|
|
98
179
|
if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
|
|
99
180
|
console.log(` ${chalk.bold('GPU:')} ${gpu}`);
|
|
181
|
+
if (flags.maxRuntime) console.log(` ${chalk.bold('Max runtime:')} ${flags.maxRuntime}min`);
|
|
182
|
+
if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
|
|
183
|
+
if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice.toFixed(2)}/hr`);
|
|
100
184
|
if (detach) console.log(` ${chalk.dim('(detached — returns immediately)')}`);
|
|
101
185
|
console.log();
|
|
186
|
+
|
|
187
|
+
// Safety guidance for jobs without a spend cap
|
|
188
|
+
if (!detach && !flags.maxRuntime && !maxCost) {
|
|
189
|
+
console.log(chalk.dim(' Tip: add --max-runtime 60 or --max-cost 5.00 to cap spend automatically'));
|
|
190
|
+
}
|
|
191
|
+
if (flags.maxRuntime > 60) {
|
|
192
|
+
console.log(chalk.yellow(' Note: jobs over 60min should checkpoint progress to recover from failures'));
|
|
193
|
+
}
|
|
194
|
+
|
|
102
195
|
console.log(chalk.dim(' Finding best available GPU capacity...'));
|
|
103
|
-
|
|
196
|
+
if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
|
|
197
|
+
console.log(chalk.dim(` API: ${config.baseUrl}`));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildBody(gpuOverride, regionOverride) {
|
|
201
|
+
const effectiveRegion = regionOverride ?? (flags.region ? flags.region.toUpperCase() : undefined);
|
|
202
|
+
return {
|
|
203
|
+
command,
|
|
204
|
+
image,
|
|
205
|
+
gpu: (gpuOverride || gpu).toUpperCase().replace('-', '_'),
|
|
206
|
+
gpu_count: flags.count || 1,
|
|
207
|
+
...(effectiveRegion ? { region: effectiveRegion } : {}),
|
|
208
|
+
max_price_per_hour: flags.maxPrice,
|
|
209
|
+
name: flags.name,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
104
212
|
|
|
105
213
|
let dep;
|
|
106
214
|
try {
|
|
@@ -108,34 +216,58 @@ export async function runCommand(config, args, chalk) {
|
|
|
108
216
|
method: 'POST',
|
|
109
217
|
apiKey: config.apiKey,
|
|
110
218
|
baseUrl: config.baseUrl,
|
|
111
|
-
body:
|
|
112
|
-
command,
|
|
113
|
-
image,
|
|
114
|
-
gpu: gpu.toUpperCase().replace('-', '_'),
|
|
115
|
-
gpu_count: flags.count || 1,
|
|
116
|
-
...(flags.region ? { region: flags.region.toUpperCase() } : {}),
|
|
117
|
-
max_price_per_hour: flags.maxPrice,
|
|
118
|
-
name: flags.name,
|
|
119
|
-
},
|
|
219
|
+
body: buildBody(),
|
|
120
220
|
});
|
|
121
221
|
} catch (err) {
|
|
122
222
|
const d = err.errorData;
|
|
123
223
|
if (d?.code === 'NO_CAPACITY_MATCH') {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
224
|
+
if (fallbackMode === 'none') {
|
|
225
|
+
console.error(chalk.red(`\n ✗ ${gpu} isn't available right now.\n`));
|
|
226
|
+
console.error(chalk.dim(' Pass --fallback closest to auto-select an alternative.'));
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Merge same-GPU-other-regions and cross-GPU alternatives into one ranked list.
|
|
231
|
+
const pool = [
|
|
232
|
+
...(Array.isArray(d.same_gpu_other_regions) ? d.same_gpu_other_regions : []),
|
|
233
|
+
...(Array.isArray(d.alternatives) ? d.alternatives : []),
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
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 increasing --max-price or contact support for a manual quote.'));
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const ranked = rankAlternatives(gpu, pool, fallbackMode);
|
|
243
|
+
const chosen = await promptFallback(gpu, ranked, chalk);
|
|
244
|
+
|
|
245
|
+
if (!chosen) {
|
|
246
|
+
console.log(chalk.dim('\n Cancelled.\n'));
|
|
247
|
+
process.exit(0);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
console.log(chalk.dim(`\n Running on ${chosen.gpu} in ${chosen.region}...\n`));
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
dep = await callApi('/run', {
|
|
254
|
+
method: 'POST',
|
|
255
|
+
apiKey: config.apiKey,
|
|
256
|
+
baseUrl: config.baseUrl,
|
|
257
|
+
body: buildBody(chosen.gpu, chosen.region),
|
|
258
|
+
});
|
|
259
|
+
} catch (err2) {
|
|
260
|
+
console.error(chalk.red(`\n ✗ Job failed to start on ${chosen.gpu}: ${err2.message}`));
|
|
261
|
+
console.error(chalk.dim(`\n Debug: BADGR_DEBUG=1 badgr run … — shows full request/response\n`));
|
|
262
|
+
process.exit(1);
|
|
130
263
|
}
|
|
131
|
-
console.error('');
|
|
132
264
|
} else {
|
|
133
265
|
console.error(chalk.red(`\n ✗ Job failed to start: ${err.message}`));
|
|
134
266
|
console.error(chalk.dim(`\n Debug: BADGR_DEBUG=1 badgr run … — shows full request/response`));
|
|
135
267
|
console.error(chalk.dim(` Config: badgr config`));
|
|
136
268
|
console.error(chalk.dim(` Docs: badgr --help\n`));
|
|
269
|
+
process.exit(1);
|
|
137
270
|
}
|
|
138
|
-
process.exit(1);
|
|
139
271
|
}
|
|
140
272
|
|
|
141
273
|
const rcptId = dep.receipt_id || generateReceiptId();
|
|
@@ -160,18 +292,73 @@ export async function runCommand(config, args, chalk) {
|
|
|
160
292
|
return;
|
|
161
293
|
}
|
|
162
294
|
|
|
163
|
-
// ── Attached mode: stream logs until job completes
|
|
164
|
-
|
|
295
|
+
// ── Attached mode: stream logs until job completes ────────────────────────
|
|
296
|
+
const ratePerHour = dep.cost_per_hour || 0;
|
|
297
|
+
console.log(chalk.dim('\n ── Attaching (Ctrl+C tears down job) ─────────────────────────\n'));
|
|
165
298
|
|
|
166
|
-
|
|
299
|
+
// Teardown helper — called on SIGINT, max-runtime, max-cost, or heartbeat loss.
|
|
300
|
+
async function teardown(reason) {
|
|
301
|
+
const labels = {
|
|
302
|
+
'max-runtime': chalk.yellow('\n ⏱ Max runtime reached — stopping job...'),
|
|
303
|
+
'max-cost': chalk.yellow('\n 💰 Spend cap reached — stopping job...'),
|
|
304
|
+
'heartbeat-lost': chalk.red('\n ✗ Heartbeat lost — infrastructure failure, stopping job...'),
|
|
305
|
+
'interrupted': chalk.yellow('\n Stopping job...'),
|
|
306
|
+
};
|
|
307
|
+
console.log(labels[reason] ?? chalk.yellow('\n Stopping job...'));
|
|
308
|
+
try {
|
|
309
|
+
await terminateDeployment(config, dep.deployment_id);
|
|
310
|
+
} catch {
|
|
311
|
+
// best-effort
|
|
312
|
+
}
|
|
313
|
+
const runtimeMs = Date.now() - attachStart;
|
|
314
|
+
const finalCost = ratePerHour * (runtimeMs / 3_600_000);
|
|
315
|
+
const failureType = reason === 'heartbeat-lost' ? 'infrastructure' : null;
|
|
316
|
+
updateReceipt(rcptId, { status: reason, runtimeSeconds: Math.round(runtimeMs / 1000), finalCost, failureType });
|
|
317
|
+
console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Est. cost: $${finalCost.toFixed(4)}`));
|
|
318
|
+
console.log(chalk.dim(' Job stopped. Billing ended.\n'));
|
|
319
|
+
process.exit(reason === 'interrupted' ? 0 : 1);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const attachStart = Date.now();
|
|
323
|
+
const { status: finalStatus, exitCode, runtimeMs, failureType } = await attachToJob(config, dep.deployment_id, {
|
|
324
|
+
chalk,
|
|
325
|
+
maxRuntimeMs,
|
|
326
|
+
maxCost,
|
|
327
|
+
ratePerHour,
|
|
328
|
+
onTeardown: teardown,
|
|
329
|
+
});
|
|
167
330
|
|
|
168
331
|
console.log();
|
|
169
332
|
|
|
170
|
-
|
|
171
|
-
|
|
333
|
+
const finalCost = ratePerHour * (runtimeMs / 3_600_000);
|
|
334
|
+
updateReceipt(rcptId, {
|
|
335
|
+
status: finalStatus,
|
|
336
|
+
exitCode,
|
|
337
|
+
runtimeSeconds: Math.round(runtimeMs / 1000),
|
|
338
|
+
finalCost,
|
|
339
|
+
failureType,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// ── Job summary ──────────────────────────────────────────────────────────
|
|
343
|
+
console.log(` ${chalk.bold('Runtime:')} ${fmtRuntime(runtimeMs)}`);
|
|
344
|
+
if (ratePerHour > 0) {
|
|
345
|
+
console.log(` ${chalk.bold('Cost:')} $${finalCost.toFixed(4)} (${chalk.dim(`$${ratePerHour.toFixed(2)}/hr`)})`);
|
|
346
|
+
}
|
|
347
|
+
if (exitCode !== null && exitCode !== undefined) {
|
|
348
|
+
console.log(` ${chalk.bold('Exit code:')} ${exitCode !== 0 ? chalk.red(exitCode) : chalk.green(exitCode)}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
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
|
+
if (failureType === 'infrastructure') {
|
|
357
|
+
console.error(chalk.dim(' This is a provider-side failure. Contact support with your receipt ID.'));
|
|
358
|
+
}
|
|
172
359
|
console.error(chalk.dim(` Logs: badgr logs ${dep.deployment_id}\n`));
|
|
173
|
-
process.exit(1);
|
|
360
|
+
process.exit(exitCode ?? 1);
|
|
174
361
|
} else {
|
|
175
|
-
console.log(chalk.green(`\n ✓ Job complete
|
|
362
|
+
console.log(chalk.green(`\n ✓ Job complete\n`));
|
|
176
363
|
}
|
|
177
364
|
}
|
package/src/commands/serve.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { requireApiKey } from '../config.js';
|
|
2
2
|
import { callApi } from '../api.js';
|
|
3
|
-
import { addDeployment, addReceipt, generateReceiptId } from '../store.js';
|
|
3
|
+
import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../store.js';
|
|
4
|
+
import { rankAlternatives, promptFallback } from '../fallback.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S
|
|
@@ -69,7 +70,11 @@ export async function serveCommand(config, args, chalk) {
|
|
|
69
70
|
console.log(` ${chalk.bold('GPU:')} ${gpu}`);
|
|
70
71
|
console.log();
|
|
71
72
|
console.log(chalk.dim(' Finding best available GPU capacity...'));
|
|
72
|
-
|
|
73
|
+
if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
|
|
74
|
+
console.log(chalk.dim(` API: ${config.baseUrl}`));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
|
|
73
78
|
|
|
74
79
|
let dep;
|
|
75
80
|
try {
|
|
@@ -81,17 +86,73 @@ export async function serveCommand(config, args, chalk) {
|
|
|
81
86
|
model,
|
|
82
87
|
gpu: gpu.toUpperCase().replace('-', '_'),
|
|
83
88
|
gpu_count: flags.count || 1,
|
|
84
|
-
region:
|
|
89
|
+
...(effectiveRegion ? { region: effectiveRegion } : {}),
|
|
85
90
|
max_price_per_hour: flags.maxPrice,
|
|
86
91
|
name: flags.name,
|
|
87
92
|
},
|
|
88
93
|
});
|
|
89
94
|
} catch (err) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
+
const d = err.errorData;
|
|
96
|
+
if (d?.code === 'NO_CAPACITY_MATCH') {
|
|
97
|
+
const pool = [
|
|
98
|
+
...(Array.isArray(d.same_gpu_other_regions) ? d.same_gpu_other_regions : []),
|
|
99
|
+
...(Array.isArray(d.alternatives) ? d.alternatives : []),
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
if (pool.length === 0) {
|
|
103
|
+
console.error(chalk.red(`\n ✗ ${gpu} isn't available right now and no alternatives found.\n`));
|
|
104
|
+
console.error(chalk.dim(' Try increasing --max-price or contact support for a manual quote.'));
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ranked = rankAlternatives(gpu, pool, 'closest');
|
|
109
|
+
const chosen = await promptFallback(gpu, ranked, chalk);
|
|
110
|
+
|
|
111
|
+
if (!chosen) {
|
|
112
|
+
console.log(chalk.dim('\n Cancelled.\n'));
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.log(chalk.dim(`\n Serving on ${chosen.gpu} in ${chosen.region}...\n`));
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
dep = await callApi('/serve', {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
apiKey: config.apiKey,
|
|
122
|
+
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
|
+
},
|
|
131
|
+
});
|
|
132
|
+
} catch (err2) {
|
|
133
|
+
console.error(chalk.red(`\n ✗ Serve failed on ${chosen.gpu}: ${err2.message}`));
|
|
134
|
+
console.error(chalk.dim(`\n Debug: BADGR_DEBUG=1 badgr serve … — shows full request/response\n`));
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
// Write a failure receipt so every serve attempt is auditable.
|
|
139
|
+
const failRcptId = generateReceiptId();
|
|
140
|
+
addReceipt({
|
|
141
|
+
receiptId: failRcptId,
|
|
142
|
+
action: 'badgr serve',
|
|
143
|
+
model,
|
|
144
|
+
gpu,
|
|
145
|
+
status: 'failed',
|
|
146
|
+
failureType: 'infrastructure',
|
|
147
|
+
createdAt: new Date().toISOString(),
|
|
148
|
+
});
|
|
149
|
+
console.error(chalk.red(`\n ✗ Serve failed: ${err.message}`));
|
|
150
|
+
console.error(chalk.dim(`\n Receipt: ${failRcptId}`));
|
|
151
|
+
console.error(chalk.dim(` Debug: BADGR_DEBUG=1 badgr serve … — shows full request/response`));
|
|
152
|
+
console.error(chalk.dim(` Config: badgr config`));
|
|
153
|
+
console.error(chalk.dim(` Docs: badgr --help\n`));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
95
156
|
}
|
|
96
157
|
|
|
97
158
|
// Mirror to local store so badgr down/status/receipts work offline
|
|
@@ -127,21 +188,27 @@ export async function serveCommand(config, args, chalk) {
|
|
|
127
188
|
let endpointReady = false;
|
|
128
189
|
|
|
129
190
|
if (flags.noWait) {
|
|
130
|
-
console.log(chalk.yellow('\n⏳
|
|
191
|
+
console.log(chalk.yellow('\n⏳ Skipped health check (--no-wait)\n'));
|
|
131
192
|
} else {
|
|
132
193
|
console.log(chalk.dim('\n Health-checking endpoint (up to 5 min)...'));
|
|
133
194
|
endpointReady = await waitForEndpoint(endpointUrl, 5 * 60 * 1000, chalk);
|
|
134
195
|
process.stdout.write('\n');
|
|
196
|
+
if (!endpointReady) {
|
|
197
|
+
updateReceipt(rcptId, { status: 'health_check_timeout' });
|
|
198
|
+
}
|
|
135
199
|
}
|
|
136
200
|
|
|
137
201
|
// ── Print result ──────────────────────────────────────────────────────────
|
|
138
202
|
if (endpointReady) {
|
|
139
203
|
console.log(chalk.green('✓ Endpoint ready\n'));
|
|
140
204
|
} else {
|
|
141
|
-
console.log(chalk.yellow('⏳ Endpoint starting\n'));
|
|
142
|
-
console.log(chalk.dim(
|
|
205
|
+
console.log(chalk.yellow('⏳ Endpoint still starting (health check timed out)\n'));
|
|
206
|
+
console.log(chalk.dim(' The deployment was provisioned but did not respond within 5 minutes.'));
|
|
207
|
+
console.log(chalk.dim(' It may still be loading the model. Check:'));
|
|
143
208
|
console.log(chalk.dim(` badgr status`));
|
|
209
|
+
console.log(chalk.dim(` badgr logs ${dep.deployment_id}`));
|
|
144
210
|
console.log(chalk.dim(` curl ${endpointUrl}/models`));
|
|
211
|
+
console.log(chalk.dim(` If it never starts, run: badgr down ${dep.deployment_id}`));
|
|
145
212
|
console.log();
|
|
146
213
|
}
|
|
147
214
|
|
|
@@ -150,6 +217,7 @@ export async function serveCommand(config, args, chalk) {
|
|
|
150
217
|
console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
|
|
151
218
|
console.log(` ${chalk.bold('Endpoint:')} ${chalk.cyan(endpointUrl)}`);
|
|
152
219
|
if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
|
|
220
|
+
console.log(` ${chalk.bold('Logs:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
|
|
153
221
|
console.log(`\n ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
154
222
|
|
|
155
223
|
if (endpointReady) {
|
package/src/fallback.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import readline from 'readline';
|
|
2
|
+
|
|
3
|
+
// Static GPU metadata for local ranking. Routing still happens server-side.
|
|
4
|
+
const GPU_META = {
|
|
5
|
+
RTX_3080: { vramGb: 10, family: 'consumer', tags: ['inference', 'dev'], desc: 'Dev, light inference' },
|
|
6
|
+
RTX_4080: { vramGb: 16, family: 'consumer', tags: ['inference', 'dev'], desc: 'Inference and dev workloads' },
|
|
7
|
+
RTX_4090: { vramGb: 24, family: 'consumer', tags: ['inference', 'training', 'dev'], desc: 'Inference, training, dev' },
|
|
8
|
+
L40S: { vramGb: 48, family: 'datacenter', tags: ['inference', 'training'], desc: 'Inference, vLLM, batch jobs' },
|
|
9
|
+
A6000: { vramGb: 48, family: 'datacenter', tags: ['training', 'inference'], desc: 'Training, large models, inference' },
|
|
10
|
+
A100: { vramGb: 40, family: 'datacenter', tags: ['training', 'inference'], desc: 'Large-scale training and inference' },
|
|
11
|
+
H100: { vramGb: 80, family: 'datacenter', tags: ['training', 'large-model'], desc: 'Large model training, best throughput' },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function altScore(alt, reqMeta) {
|
|
15
|
+
const meta = GPU_META[alt.gpu] ?? { vramGb: 0, family: 'unknown', tags: [] };
|
|
16
|
+
let s = 0;
|
|
17
|
+
|
|
18
|
+
// 1. GPU class/family similarity
|
|
19
|
+
if (meta.family === reqMeta.family) s += 200;
|
|
20
|
+
|
|
21
|
+
// 2. VRAM — prefer same or more, penalize less
|
|
22
|
+
const vramDiff = meta.vramGb - reqMeta.vramGb;
|
|
23
|
+
if (vramDiff >= 0) {
|
|
24
|
+
s += 80 - Math.min(vramDiff, 40) * 0.5;
|
|
25
|
+
} else {
|
|
26
|
+
s += 80 + vramDiff * 4;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 3. Workload tag overlap
|
|
30
|
+
s += reqMeta.tags.filter(t => meta.tags.includes(t)).length * 20;
|
|
31
|
+
|
|
32
|
+
// 4. Availability confidence — not in alternatives payload, treated equal
|
|
33
|
+
|
|
34
|
+
// 5. Price — lower wins
|
|
35
|
+
s -= alt.price * 10;
|
|
36
|
+
|
|
37
|
+
return s;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Rank a list of { gpu, region, price } alternatives.
|
|
42
|
+
* mode 'closest' → family / VRAM / tag / price (default)
|
|
43
|
+
* mode 'cheapest' → price ascending only
|
|
44
|
+
*/
|
|
45
|
+
export function rankAlternatives(requestedGpu, alternatives, mode = 'closest') {
|
|
46
|
+
if (!alternatives || alternatives.length === 0) return [];
|
|
47
|
+
if (mode === 'cheapest') return [...alternatives].sort((a, b) => a.price - b.price);
|
|
48
|
+
|
|
49
|
+
const reqMeta = GPU_META[requestedGpu] ?? { vramGb: 0, family: 'unknown', tags: [] };
|
|
50
|
+
return [...alternatives].sort((a, b) => altScore(b, reqMeta) - altScore(a, reqMeta));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One-line human-readable diff between requested and chosen GPU.
|
|
55
|
+
* e.g. "less VRAM (24GB vs 40GB), different GPU class than A100"
|
|
56
|
+
*/
|
|
57
|
+
export function diffDescription(requestedGpu, altGpu) {
|
|
58
|
+
const req = GPU_META[requestedGpu];
|
|
59
|
+
const alt = GPU_META[altGpu];
|
|
60
|
+
if (!req || !alt) return '';
|
|
61
|
+
|
|
62
|
+
const parts = [];
|
|
63
|
+
const vramDiff = alt.vramGb - req.vramGb;
|
|
64
|
+
if (vramDiff < -4) parts.push(`less VRAM (${alt.vramGb}GB vs ${req.vramGb}GB)`);
|
|
65
|
+
else if (vramDiff > 4) parts.push(`more VRAM (${alt.vramGb}GB vs ${req.vramGb}GB)`);
|
|
66
|
+
|
|
67
|
+
if (alt.family !== req.family) parts.push('different GPU class');
|
|
68
|
+
|
|
69
|
+
if (parts.length === 0) return `similar specs to ${requestedGpu}`;
|
|
70
|
+
return parts.join(', ') + ` than ${requestedGpu}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ask(prompt) {
|
|
74
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
75
|
+
return new Promise(resolve => rl.question(prompt, ans => { rl.close(); resolve(ans.trim()); }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Show the interactive fallback prompt and return the chosen alternative or null.
|
|
80
|
+
* Non-TTY environments auto-select the top-ranked option.
|
|
81
|
+
*/
|
|
82
|
+
export async function promptFallback(requestedGpu, ranked, chalk) {
|
|
83
|
+
if (ranked.length === 0) return null;
|
|
84
|
+
|
|
85
|
+
const top = ranked[0];
|
|
86
|
+
const others = ranked.slice(1, 4); // cap at 3 extras for readability
|
|
87
|
+
const topMeta = GPU_META[top.gpu];
|
|
88
|
+
|
|
89
|
+
console.log(chalk.yellow(`\n ${requestedGpu} isn't available right now.\n`));
|
|
90
|
+
console.log(chalk.bold(' Closest match:'));
|
|
91
|
+
console.log(` ${chalk.cyan(top.gpu)} in ${top.region}`);
|
|
92
|
+
console.log(` ${chalk.green('$' + top.price.toFixed(2) + '/hr')} estimated`);
|
|
93
|
+
if (topMeta?.desc) console.log(` ${topMeta.desc}`);
|
|
94
|
+
console.log();
|
|
95
|
+
|
|
96
|
+
if (!process.stdin.isTTY) {
|
|
97
|
+
console.log(chalk.dim(` Auto-selecting ${top.gpu} (non-interactive).`));
|
|
98
|
+
return top;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.log(` Press ${chalk.bold('Enter')} to run on ${chalk.cyan(top.gpu)}`);
|
|
102
|
+
if (others.length > 0) {
|
|
103
|
+
console.log(' or type:');
|
|
104
|
+
for (const [i, alt] of others.entries()) {
|
|
105
|
+
const meta = GPU_META[alt.gpu];
|
|
106
|
+
const hint = meta ? meta.desc.split(',')[0].toLowerCase() : '';
|
|
107
|
+
console.log(` ${chalk.bold(String(i + 1))} = ${alt.gpu}${hint ? ', ' + hint : ''}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
console.log(` ${chalk.bold('q')} = cancel`);
|
|
111
|
+
console.log();
|
|
112
|
+
|
|
113
|
+
const answer = await ask(' > ');
|
|
114
|
+
|
|
115
|
+
if (answer === '') return top;
|
|
116
|
+
if (answer.toLowerCase() === 'q') return null;
|
|
117
|
+
|
|
118
|
+
const idx = parseInt(answer, 10);
|
|
119
|
+
if (!isNaN(idx) && idx >= 1 && idx <= others.length) return others[idx - 1];
|
|
120
|
+
|
|
121
|
+
console.log(chalk.dim(` Unrecognised input — using ${top.gpu}.`));
|
|
122
|
+
return top;
|
|
123
|
+
}
|
package/src/store.js
CHANGED
|
@@ -83,6 +83,15 @@ export function addReceipt(receipt, storeFile = STORE_FILE) {
|
|
|
83
83
|
return receipt;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
export function updateReceipt(receiptId, updates, storeFile = STORE_FILE) {
|
|
87
|
+
const store = loadStore(storeFile);
|
|
88
|
+
const idx = store.receipts.findIndex(r => r.receiptId === receiptId);
|
|
89
|
+
if (idx === -1) return null;
|
|
90
|
+
store.receipts[idx] = { ...store.receipts[idx], ...updates };
|
|
91
|
+
saveStore(store, storeFile);
|
|
92
|
+
return store.receipts[idx];
|
|
93
|
+
}
|
|
94
|
+
|
|
86
95
|
export function listReceipts(limit = 20, storeFile = STORE_FILE) {
|
|
87
96
|
return loadStore(storeFile).receipts.slice(0, limit);
|
|
88
97
|
}
|
package/tests/commands.test.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { parseRunArgs } from '../src/commands/run.js';
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { parseRunArgs, classifyFailure } from '../src/commands/run.js';
|
|
3
3
|
import { parseServeArgs } from '../src/commands/serve.js';
|
|
4
|
+
import { rankAlternatives, diffDescription, promptFallback } from '../src/fallback.js';
|
|
4
5
|
|
|
5
6
|
describe('parseRunArgs', () => {
|
|
6
7
|
it('parses a plain command', () => {
|
|
@@ -43,6 +44,112 @@ describe('parseRunArgs', () => {
|
|
|
43
44
|
expect(positional).toEqual([]);
|
|
44
45
|
expect(flags.gpu).toBeUndefined();
|
|
45
46
|
});
|
|
47
|
+
|
|
48
|
+
it('parses --fallback closest', () => {
|
|
49
|
+
const { flags } = parseRunArgs(['python', 'train.py', '--gpu', 'A100', '--fallback', 'closest']);
|
|
50
|
+
expect(flags.fallback).toBe('closest');
|
|
51
|
+
expect(flags.noFallback).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('parses --fallback cheapest', () => {
|
|
55
|
+
const { flags } = parseRunArgs(['python', 'train.py', '--fallback', 'cheapest']);
|
|
56
|
+
expect(flags.fallback).toBe('cheapest');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('parses --no-fallback', () => {
|
|
60
|
+
const { flags } = parseRunArgs(['python', 'train.py', '--gpu', 'A100', '--no-fallback']);
|
|
61
|
+
expect(flags.noFallback).toBe(true);
|
|
62
|
+
expect(flags.fallback).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('parses --max-runtime as a float (minutes)', () => {
|
|
66
|
+
const { flags } = parseRunArgs(['python', 'train.py', '--max-runtime', '30']);
|
|
67
|
+
expect(flags.maxRuntime).toBe(30);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('parses --max-cost as a float (dollars)', () => {
|
|
71
|
+
const { flags } = parseRunArgs(['python', 'train.py', '--max-cost', '5.00']);
|
|
72
|
+
expect(flags.maxCost).toBe(5.0);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('classifyFailure', () => {
|
|
77
|
+
it('returns infrastructure when status is failed and no exit code', () => {
|
|
78
|
+
expect(classifyFailure('failed', null)).toBe('infrastructure');
|
|
79
|
+
expect(classifyFailure('failed', undefined)).toBe('infrastructure');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns customer_code when exit code is non-zero', () => {
|
|
83
|
+
expect(classifyFailure('failed', 1)).toBe('customer_code');
|
|
84
|
+
expect(classifyFailure('completed', 2)).toBe('customer_code');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns null for successful jobs', () => {
|
|
88
|
+
expect(classifyFailure('completed', 0)).toBeNull();
|
|
89
|
+
expect(classifyFailure('completed', null)).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('returns null for stopped jobs with no exit code', () => {
|
|
93
|
+
expect(classifyFailure('stopped', null)).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('rankAlternatives', () => {
|
|
98
|
+
const pool = [
|
|
99
|
+
{ gpu: 'RTX_4090', region: 'US', price: 0.72 },
|
|
100
|
+
{ gpu: 'L40S', region: 'US', price: 1.25 },
|
|
101
|
+
{ gpu: 'H100', region: 'EU', price: 2.80 },
|
|
102
|
+
{ gpu: 'A6000', region: 'US', price: 1.20 },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
it('cheapest mode sorts by price ascending', () => {
|
|
106
|
+
const ranked = rankAlternatives('A100', pool, 'cheapest');
|
|
107
|
+
expect(ranked[0].price).toBe(0.72);
|
|
108
|
+
expect(ranked[ranked.length - 1].price).toBe(2.80);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('closest mode prefers same GPU family over cheaper consumer GPU', () => {
|
|
112
|
+
const ranked = rankAlternatives('A100', pool, 'closest');
|
|
113
|
+
const topGpu = ranked[0].gpu;
|
|
114
|
+
// H100, A6000, and L40S are all datacenter — any of them should beat RTX_4090
|
|
115
|
+
expect(['H100', 'A6000', 'L40S']).toContain(topGpu);
|
|
116
|
+
const rtxPos = ranked.findIndex(a => a.gpu === 'RTX_4090');
|
|
117
|
+
expect(rtxPos).toBeGreaterThan(0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('returns empty array for empty pool', () => {
|
|
121
|
+
expect(rankAlternatives('A100', [])).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('returns empty array for null pool', () => {
|
|
125
|
+
expect(rankAlternatives('A100', null)).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('diffDescription', () => {
|
|
130
|
+
it('reports more VRAM when alt has significantly more', () => {
|
|
131
|
+
const desc = diffDescription('RTX_4090', 'L40S'); // 24GB vs 48GB
|
|
132
|
+
expect(desc).toContain('more VRAM');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('reports less VRAM when alt has significantly less', () => {
|
|
136
|
+
const desc = diffDescription('A100', 'RTX_4090'); // 40GB vs 24GB
|
|
137
|
+
expect(desc).toContain('less VRAM');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('reports different GPU class when family differs', () => {
|
|
141
|
+
const desc = diffDescription('A100', 'RTX_4090');
|
|
142
|
+
expect(desc).toContain('different GPU class');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('returns similar specs for same-class same VRAM GPUs', () => {
|
|
146
|
+
const desc = diffDescription('L40S', 'A6000'); // both 48GB, both datacenter
|
|
147
|
+
expect(desc).toContain('similar specs');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('returns empty string for unknown GPUs', () => {
|
|
151
|
+
expect(diffDescription('UNKNOWN', 'ALSO_UNKNOWN')).toBe('');
|
|
152
|
+
});
|
|
46
153
|
});
|
|
47
154
|
|
|
48
155
|
describe('parseServeArgs', () => {
|
|
@@ -79,3 +186,26 @@ describe('parseServeArgs', () => {
|
|
|
79
186
|
expect(model).toBeNull();
|
|
80
187
|
});
|
|
81
188
|
});
|
|
189
|
+
|
|
190
|
+
describe('promptFallback output', () => {
|
|
191
|
+
it('does not print a Difference line', async () => {
|
|
192
|
+
const lines = [];
|
|
193
|
+
const chalk = { yellow: s => s, bold: s => s, cyan: s => s, green: s => s, dim: s => s };
|
|
194
|
+
const origLog = console.log;
|
|
195
|
+
console.log = (...args) => lines.push(args.join(' '));
|
|
196
|
+
// force non-TTY so it auto-selects without asking
|
|
197
|
+
const origIsTTY = process.stdin.isTTY;
|
|
198
|
+
process.stdin.isTTY = false;
|
|
199
|
+
|
|
200
|
+
const pool = [{ gpu: 'L40S', region: 'US', price: 1.25 }];
|
|
201
|
+
await promptFallback('A100', pool, chalk);
|
|
202
|
+
|
|
203
|
+
console.log = origLog;
|
|
204
|
+
process.stdin.isTTY = origIsTTY;
|
|
205
|
+
|
|
206
|
+
const joined = lines.join('\n');
|
|
207
|
+
expect(joined).not.toContain('Difference');
|
|
208
|
+
expect(joined).toContain('L40S');
|
|
209
|
+
expect(joined).toContain('1.25');
|
|
210
|
+
});
|
|
211
|
+
});
|
package/tests/store.test.js
CHANGED
|
@@ -5,7 +5,7 @@ import { rmSync, existsSync } from 'fs';
|
|
|
5
5
|
import {
|
|
6
6
|
loadStore, saveStore,
|
|
7
7
|
addDeployment, updateDeployment, removeDeployment, findDeployment, listDeployments,
|
|
8
|
-
addReceipt, listReceipts,
|
|
8
|
+
addReceipt, updateReceipt, listReceipts,
|
|
9
9
|
generateDeploymentId, generateReceiptId,
|
|
10
10
|
} from '../src/store.js';
|
|
11
11
|
|
|
@@ -124,3 +124,43 @@ describe('addReceipt / listReceipts', () => {
|
|
|
124
124
|
expect(listReceipts(3, file)).toHaveLength(3);
|
|
125
125
|
});
|
|
126
126
|
});
|
|
127
|
+
|
|
128
|
+
describe('updateReceipt', () => {
|
|
129
|
+
it('merges updates into an existing receipt', () => {
|
|
130
|
+
addReceipt({ receiptId: 'r-upd', action: 'badgr run', status: 'running' }, file);
|
|
131
|
+
updateReceipt('r-upd', { status: 'completed', finalCost: 0.012, runtimeSeconds: 42 }, file);
|
|
132
|
+
const [r] = listReceipts(1, file);
|
|
133
|
+
expect(r.status).toBe('completed');
|
|
134
|
+
expect(r.finalCost).toBe(0.012);
|
|
135
|
+
expect(r.runtimeSeconds).toBe(42);
|
|
136
|
+
expect(r.action).toBe('badgr run'); // preserved
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns null for unknown receipt id', () => {
|
|
140
|
+
expect(updateReceipt('no-such-id', { status: 'done' }, file)).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('records a serve failure receipt with failureType=infrastructure', () => {
|
|
144
|
+
addReceipt({
|
|
145
|
+
receiptId: 'r-serve-fail',
|
|
146
|
+
action: 'badgr serve',
|
|
147
|
+
model: 'meta-llama/Llama-3.1-8B-Instruct',
|
|
148
|
+
gpu: 'L40S',
|
|
149
|
+
status: 'failed',
|
|
150
|
+
failureType: 'infrastructure',
|
|
151
|
+
createdAt: new Date().toISOString(),
|
|
152
|
+
}, file);
|
|
153
|
+
const [r] = listReceipts(1, file);
|
|
154
|
+
expect(r.failureType).toBe('infrastructure');
|
|
155
|
+
expect(r.action).toBe('badgr serve');
|
|
156
|
+
expect(r.status).toBe('failed');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('updates a serve receipt to health_check_timeout', () => {
|
|
160
|
+
addReceipt({ receiptId: 'r-hc', action: 'badgr serve', status: 'provisioning' }, file);
|
|
161
|
+
updateReceipt('r-hc', { status: 'health_check_timeout' }, file);
|
|
162
|
+
const receipts = listReceipts(10, file);
|
|
163
|
+
const r = receipts.find(x => x.receiptId === 'r-hc');
|
|
164
|
+
expect(r.status).toBe('health_check_timeout');
|
|
165
|
+
});
|
|
166
|
+
});
|