badgr-cli 1.0.8 → 1.0.10
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 +14 -6
- package/src/commands/receipts.js +19 -4
- package/src/commands/run.js +256 -92
- 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
|
@@ -26,12 +26,17 @@ ${chalk.bold('CORE COMMANDS')}
|
|
|
26
26
|
${chalk.cyan('badgr capacity [--gpu <type>]')} Check live GPU availability and alternatives
|
|
27
27
|
|
|
28
28
|
${chalk.bold('badgr run OPTIONS')}
|
|
29
|
-
--gpu <type>
|
|
30
|
-
--image <image>
|
|
31
|
-
--count <n>
|
|
32
|
-
--region US|EU|AU
|
|
33
|
-
--max-price <$/hr>
|
|
34
|
-
--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
|
|
35
40
|
|
|
36
41
|
${chalk.bold('badgr serve OPTIONS')}
|
|
37
42
|
--gpu <type> GPU: L40S, A100, H100, RTX_4090 (default: L40S)
|
|
@@ -43,6 +48,9 @@ ${chalk.bold('badgr serve OPTIONS')}
|
|
|
43
48
|
${chalk.bold('EXAMPLES')}
|
|
44
49
|
badgr login
|
|
45
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
|
|
46
54
|
badgr run --image my/image:latest --gpu L40S
|
|
47
55
|
badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S
|
|
48
56
|
badgr serve mistralai/Mistral-7B-v0.1 --gpu RTX_4090
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 };
|
|
96
|
+
}
|
|
97
|
+
|
|
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;
|
|
68
118
|
}
|
|
69
|
-
seenLines = lines.length;
|
|
70
|
-
} catch {
|
|
71
|
-
// logs not ready yet
|
|
72
|
-
}
|
|
73
119
|
|
|
74
|
-
|
|
75
|
-
|
|
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,57 +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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
console.error(chalk.dim(` from $${a.price.toFixed(2)}/hr`));
|
|
139
|
-
}
|
|
140
|
-
} else if (alternatives.length > 0) {
|
|
141
|
-
// Global search failed — show what is actually available.
|
|
142
|
-
console.error(chalk.dim(' Available right now:\n'));
|
|
143
|
-
for (const a of alternatives) {
|
|
144
|
-
console.error(chalk.dim(` • ${a.gpu} in ${a.region} from $${a.price.toFixed(2)}/hr`));
|
|
145
|
-
}
|
|
146
|
-
console.error(chalk.dim('\n Try:'));
|
|
147
|
-
for (const a of alternatives) {
|
|
148
|
-
console.error(chalk.cyan(` badgr run ${cmdPrefix} --gpu ${a.gpu}`));
|
|
149
|
-
}
|
|
150
|
-
} else {
|
|
151
|
-
console.error(chalk.dim(' No alternative GPU capacity found right now.'));
|
|
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`));
|
|
152
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);
|
|
153
263
|
}
|
|
154
|
-
console.error('');
|
|
155
264
|
} else {
|
|
156
265
|
console.error(chalk.red(`\n ✗ Job failed to start: ${err.message}`));
|
|
157
266
|
console.error(chalk.dim(`\n Debug: BADGR_DEBUG=1 badgr run … — shows full request/response`));
|
|
158
267
|
console.error(chalk.dim(` Config: badgr config`));
|
|
159
268
|
console.error(chalk.dim(` Docs: badgr --help\n`));
|
|
269
|
+
process.exit(1);
|
|
160
270
|
}
|
|
161
|
-
process.exit(1);
|
|
162
271
|
}
|
|
163
272
|
|
|
164
273
|
const rcptId = dep.receipt_id || generateReceiptId();
|
|
@@ -183,18 +292,73 @@ export async function runCommand(config, args, chalk) {
|
|
|
183
292
|
return;
|
|
184
293
|
}
|
|
185
294
|
|
|
186
|
-
// ── Attached mode: stream logs until job completes
|
|
187
|
-
|
|
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'));
|
|
298
|
+
|
|
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
|
+
}
|
|
188
321
|
|
|
189
|
-
const
|
|
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
|
+
});
|
|
190
330
|
|
|
191
331
|
console.log();
|
|
192
332
|
|
|
193
|
-
|
|
194
|
-
|
|
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
|
+
}
|
|
195
359
|
console.error(chalk.dim(` Logs: badgr logs ${dep.deployment_id}\n`));
|
|
196
|
-
process.exit(1);
|
|
360
|
+
process.exit(exitCode ?? 1);
|
|
197
361
|
} else {
|
|
198
|
-
console.log(chalk.green(`\n ✓ Job complete
|
|
362
|
+
console.log(chalk.green(`\n ✓ Job complete\n`));
|
|
199
363
|
}
|
|
200
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
|
+
});
|