badgr-cli 1.0.12 → 1.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -4
- package/src/badgr.js +46 -50
- package/src/commands/capacity.js +63 -56
- package/src/commands/down.js +26 -23
- package/src/commands/login.js +20 -3
- package/src/commands/run.js +132 -75
- package/src/commands/serve.js +57 -75
- package/src/commands/status.js +35 -48
- package/src/commands/up.js +32 -26
- package/src/fallback.js +34 -63
- package/src/router.js +8 -69
- package/tests/commands.test.js +30 -28
- package/tests/router.test.js +9 -68
package/src/commands/run.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
|
+
import readline from 'readline';
|
|
1
2
|
import { requireApiKey } from '../config.js';
|
|
2
3
|
import { callApi, terminateDeployment } from '../api.js';
|
|
3
4
|
import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
|
|
4
5
|
import { rankAlternatives, promptFallback } from '../fallback.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
* badgr run python train.py
|
|
8
|
+
* badgr run python train.py # gpu=auto, attached
|
|
9
|
+
* badgr run python train.py --gpu A100 # specific GPU
|
|
8
10
|
* badgr run --image my/image:latest --gpu L40S --detach
|
|
9
|
-
*
|
|
10
|
-
* Attaches by default: polls status + streams logs until the job finishes,
|
|
11
|
-
* then exits with the job's exit code. Pass --detach to return immediately.
|
|
12
11
|
*/
|
|
13
12
|
export function parseRunArgs(args) {
|
|
14
13
|
const flags = {};
|
|
@@ -38,26 +37,15 @@ function fmtRuntime(ms) {
|
|
|
38
37
|
return `${m}m ${s % 60}s`;
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
const HEARTBEAT_KILL_POLLS = 15; // ~60s → treat as infrastructure failure
|
|
40
|
+
const HEARTBEAT_WARN_POLLS = 3;
|
|
41
|
+
const HEARTBEAT_KILL_POLLS = 15;
|
|
44
42
|
|
|
45
|
-
/**
|
|
46
|
-
* Classify a failure into 'customer_code' or 'infrastructure'.
|
|
47
|
-
* customer_code: process ran and exited non-zero.
|
|
48
|
-
* infrastructure: provider issue — job never completed normally.
|
|
49
|
-
*/
|
|
50
43
|
export function classifyFailure(finalStatus, exitCode) {
|
|
51
44
|
if (finalStatus === 'failed' && (exitCode === null || exitCode === undefined)) return 'infrastructure';
|
|
52
45
|
if (exitCode !== null && exitCode !== undefined && exitCode !== 0) return 'customer_code';
|
|
53
46
|
return null;
|
|
54
47
|
}
|
|
55
48
|
|
|
56
|
-
/**
|
|
57
|
-
* Poll until terminal status, streaming new log lines.
|
|
58
|
-
* Returns { status, exitCode, runtimeMs, failureType }.
|
|
59
|
-
* Calls onTeardown(reason) when SIGINT, maxRuntime, maxCost, or heartbeat loss hits.
|
|
60
|
-
*/
|
|
61
49
|
async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost = null, ratePerHour = 0, onTeardown }) {
|
|
62
50
|
const TERMINAL = new Set(['stopped', 'failed', 'completed']);
|
|
63
51
|
const POLL_MS = 4000;
|
|
@@ -81,14 +69,12 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
81
69
|
const elapsedMs = Date.now() - startMs;
|
|
82
70
|
const spentSoFar = ratePerHour * (elapsedMs / 3_600_000);
|
|
83
71
|
|
|
84
|
-
// Hard spend cap
|
|
85
72
|
if (maxCost !== null && spentSoFar >= maxCost) {
|
|
86
73
|
tearing = true;
|
|
87
74
|
onTeardown('max-cost');
|
|
88
75
|
return { status: 'timeout', exitCode: null, runtimeMs: elapsedMs, failureType: null };
|
|
89
76
|
}
|
|
90
77
|
|
|
91
|
-
// Hard runtime cap
|
|
92
78
|
if (maxRuntimeMs !== null && elapsedMs >= maxRuntimeMs) {
|
|
93
79
|
tearing = true;
|
|
94
80
|
onTeardown('max-runtime');
|
|
@@ -107,7 +93,7 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
107
93
|
if (lastStatus === 'running') {
|
|
108
94
|
const lostSec = Math.round((consecutiveErrs * POLL_MS) / 1000);
|
|
109
95
|
if (consecutiveErrs === HEARTBEAT_WARN_POLLS) {
|
|
110
|
-
console.log(chalk.yellow(`\n ⚠ No heartbeat for ${lostSec}s —
|
|
96
|
+
console.log(chalk.yellow(`\n ⚠ No heartbeat for ${lostSec}s — cloud machine may be unresponsive`));
|
|
111
97
|
} else if (consecutiveErrs >= HEARTBEAT_KILL_POLLS) {
|
|
112
98
|
tearing = true;
|
|
113
99
|
onTeardown('heartbeat-lost');
|
|
@@ -155,41 +141,103 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
155
141
|
}
|
|
156
142
|
}
|
|
157
143
|
|
|
144
|
+
// Ask the backend for the cheapest available GPU right now.
|
|
145
|
+
// Returns { gpu, region, price } or null when nothing is available.
|
|
146
|
+
async function findAutoGpu(config, chalk) {
|
|
147
|
+
try {
|
|
148
|
+
const params = new URLSearchParams({ max_price: '10' });
|
|
149
|
+
return await callApi(`/capacity/auto?${params}`, {
|
|
150
|
+
apiKey: config.apiKey,
|
|
151
|
+
baseUrl: config.baseUrl,
|
|
152
|
+
});
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (err.errorData?.code === 'NO_CAPACITY_MATCH') return null;
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function askConfirm(prompt) {
|
|
160
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
161
|
+
return new Promise(resolve => rl.question(prompt, ans => { rl.close(); resolve(ans.trim()); }));
|
|
162
|
+
}
|
|
163
|
+
|
|
158
164
|
export async function runCommand(config, args, chalk) {
|
|
159
165
|
const { flags, positional } = parseRunArgs(args);
|
|
160
166
|
|
|
161
167
|
if (positional.length === 0 && !flags.image) {
|
|
162
|
-
console.error(chalk.red('Usage: badgr run <command...>
|
|
163
|
-
console.error(chalk.red(' badgr run --image my/image:latest
|
|
168
|
+
console.error(chalk.red('Usage: badgr run <command...>'));
|
|
169
|
+
console.error(chalk.red(' badgr run --image my/image:latest'));
|
|
164
170
|
return;
|
|
165
171
|
}
|
|
166
172
|
|
|
167
173
|
requireApiKey(config);
|
|
168
174
|
|
|
169
|
-
const command
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
175
|
+
const command = positional.length > 0 ? positional : undefined;
|
|
176
|
+
const image = flags.image || (command ? 'python:3.11-slim' : undefined);
|
|
177
|
+
const detach = flags.detach || false;
|
|
178
|
+
const fallbackMode = flags.noFallback ? 'none' : (flags.fallback || 'closest');
|
|
179
|
+
const maxRuntimeMs = flags.maxRuntime ? flags.maxRuntime * 60 * 1000 : null;
|
|
180
|
+
const maxCost = flags.maxCost ?? null;
|
|
181
|
+
|
|
182
|
+
// ── Auto GPU selection (no --gpu specified) ────────────────────────────────
|
|
183
|
+
let gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : null;
|
|
184
|
+
let autoRegion = null;
|
|
185
|
+
|
|
186
|
+
if (!gpu) {
|
|
187
|
+
console.log(chalk.bold('\n⚡ Running GPU job\n'));
|
|
188
|
+
if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
|
|
189
|
+
if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
|
|
190
|
+
console.log();
|
|
191
|
+
process.stdout.write(chalk.dim(' Finding GPU...'));
|
|
192
|
+
|
|
193
|
+
let best;
|
|
194
|
+
try {
|
|
195
|
+
best = await findAutoGpu(config, chalk);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
process.stdout.write('\n');
|
|
198
|
+
console.error(chalk.red(`\n ✗ Could not find GPU capacity: ${err.message}\n`));
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
186
201
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
202
|
+
process.stdout.write('\n');
|
|
203
|
+
|
|
204
|
+
if (!best) {
|
|
205
|
+
console.error(chalk.red('\n ✗ No GPU capacity available right now.\n'));
|
|
206
|
+
console.error(chalk.dim(' Run `badgr capacity` for details, or try again in a few minutes.'));
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
console.log(`\n ${chalk.bold('Best match:')} ${chalk.cyan(best.gpu)} in ${best.region} — ${chalk.green('$' + best.price.toFixed(2) + '/hr')} estimated`);
|
|
211
|
+
console.log();
|
|
212
|
+
|
|
213
|
+
if (process.stdin.isTTY) {
|
|
214
|
+
const answer = await askConfirm(` Press ${chalk.bold('Enter')} to run, or ${chalk.bold('q')} to cancel: `);
|
|
215
|
+
if (answer.toLowerCase() === 'q') {
|
|
216
|
+
console.log(chalk.dim('\n Cancelled.\n'));
|
|
217
|
+
process.exit(0);
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
console.log(chalk.dim(` Auto-selecting ${best.gpu} (non-interactive).`));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
gpu = best.gpu;
|
|
224
|
+
autoRegion = best.region;
|
|
225
|
+
console.log();
|
|
226
|
+
} else {
|
|
227
|
+
// Specific GPU requested — show header
|
|
228
|
+
console.log(chalk.bold('\n⚡ Running GPU job\n'));
|
|
229
|
+
if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
|
|
230
|
+
if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
|
|
231
|
+
console.log(` ${chalk.bold('GPU:')} ${gpu}`);
|
|
232
|
+
if (flags.maxRuntime) console.log(` ${chalk.bold('Max runtime:')} ${flags.maxRuntime}min`);
|
|
233
|
+
if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
|
|
234
|
+
if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice.toFixed(2)}/hr`);
|
|
235
|
+
if (detach) console.log(` ${chalk.dim('(detached — returns immediately)')}`);
|
|
236
|
+
console.log();
|
|
237
|
+
|
|
238
|
+
if (!detach && !flags.maxRuntime && !maxCost) {
|
|
239
|
+
console.log(chalk.dim(' Tip: add --max-runtime 60 or --max-cost 5.00 to cap spend automatically'));
|
|
240
|
+
}
|
|
193
241
|
}
|
|
194
242
|
|
|
195
243
|
console.log(chalk.dim(' Finding best available GPU capacity...'));
|
|
@@ -198,11 +246,13 @@ export async function runCommand(config, args, chalk) {
|
|
|
198
246
|
}
|
|
199
247
|
|
|
200
248
|
function buildBody(gpuOverride, regionOverride) {
|
|
201
|
-
const effectiveRegion = regionOverride
|
|
249
|
+
const effectiveRegion = regionOverride
|
|
250
|
+
?? autoRegion
|
|
251
|
+
?? (flags.region ? flags.region.toUpperCase() : undefined);
|
|
202
252
|
return {
|
|
203
253
|
command,
|
|
204
254
|
image,
|
|
205
|
-
gpu: (gpuOverride || gpu)
|
|
255
|
+
gpu: (gpuOverride || gpu),
|
|
206
256
|
gpu_count: flags.count || 1,
|
|
207
257
|
...(effectiveRegion ? { region: effectiveRegion } : {}),
|
|
208
258
|
max_price_per_hour: flags.maxPrice,
|
|
@@ -227,15 +277,14 @@ export async function runCommand(config, args, chalk) {
|
|
|
227
277
|
process.exit(1);
|
|
228
278
|
}
|
|
229
279
|
|
|
230
|
-
// Merge same-GPU-other-regions and cross-GPU alternatives into one ranked list.
|
|
231
280
|
const pool = [
|
|
232
281
|
...(Array.isArray(d.same_gpu_other_regions) ? d.same_gpu_other_regions : []),
|
|
233
282
|
...(Array.isArray(d.alternatives) ? d.alternatives : []),
|
|
234
283
|
];
|
|
235
284
|
|
|
236
285
|
if (pool.length === 0) {
|
|
237
|
-
console.error(chalk.red(`\n ✗ ${gpu} isn't available right now and no alternatives found.\n`));
|
|
238
|
-
console.error(chalk.dim(' Try
|
|
286
|
+
console.error(chalk.red(`\n ✗ ${gpu} isn't available right now and no alternatives were found.\n`));
|
|
287
|
+
console.error(chalk.dim(' Try `badgr capacity` to see what\'s available, or try again shortly.'));
|
|
239
288
|
process.exit(1);
|
|
240
289
|
}
|
|
241
290
|
|
|
@@ -247,7 +296,7 @@ export async function runCommand(config, args, chalk) {
|
|
|
247
296
|
process.exit(0);
|
|
248
297
|
}
|
|
249
298
|
|
|
250
|
-
console.log(chalk.dim(`\n
|
|
299
|
+
console.log(chalk.dim(`\n Trying ${chosen.gpu} in ${chosen.region}...\n`));
|
|
251
300
|
|
|
252
301
|
try {
|
|
253
302
|
dep = await callApi('/run', {
|
|
@@ -259,19 +308,30 @@ export async function runCommand(config, args, chalk) {
|
|
|
259
308
|
} catch (err2) {
|
|
260
309
|
const d2 = err2.errorData;
|
|
261
310
|
if (d2?.code === 'PROVISIONING_FAILED' || d2?.code === 'PROVIDER_ADAPTER_ERROR') {
|
|
262
|
-
console.error(chalk.red(`\n ✗ Badgr found ${chosen.gpu} capacity
|
|
263
|
-
if (
|
|
311
|
+
console.error(chalk.red(`\n ✗ Badgr found ${chosen.gpu} capacity but could not start the machine. Please try again.\n`));
|
|
312
|
+
if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
|
|
313
|
+
if (d2?.debug_error) console.error(chalk.dim(` Provider detail: ${d2.debug_error}`));
|
|
314
|
+
}
|
|
264
315
|
} else {
|
|
265
|
-
console.error(chalk.red(`\n ✗ Job failed to start on ${chosen.gpu}: ${err2.message}`));
|
|
316
|
+
console.error(chalk.red(`\n ✗ Job failed to start on ${chosen.gpu}: ${err2.message}\n`));
|
|
317
|
+
}
|
|
318
|
+
if (!(process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true')) {
|
|
319
|
+
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.\n`));
|
|
266
320
|
}
|
|
267
|
-
console.error(chalk.dim(`\n Debug: BADGR_DEBUG=1 badgr run … — shows full request/response\n`));
|
|
268
321
|
process.exit(1);
|
|
269
322
|
}
|
|
323
|
+
} else if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
|
|
324
|
+
console.error(chalk.red(`\n ✗ Badgr found ${gpu} capacity but could not start the machine. Please try again.\n`));
|
|
325
|
+
if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
|
|
326
|
+
if (d?.debug_error) console.error(chalk.dim(` Provider detail: ${d.debug_error}`));
|
|
327
|
+
} else {
|
|
328
|
+
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.\n`));
|
|
329
|
+
}
|
|
330
|
+
process.exit(1);
|
|
270
331
|
} else {
|
|
271
|
-
console.error(chalk.red(`\n ✗
|
|
272
|
-
console.error(chalk.dim(
|
|
273
|
-
console.error(chalk.dim(`
|
|
274
|
-
console.error(chalk.dim(` Docs: badgr --help\n`));
|
|
332
|
+
console.error(chalk.red(`\n ✗ Could not start job: ${err.message}\n`));
|
|
333
|
+
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.`));
|
|
334
|
+
console.error(chalk.dim(` Check config: badgr config\n`));
|
|
275
335
|
process.exit(1);
|
|
276
336
|
}
|
|
277
337
|
}
|
|
@@ -281,13 +341,13 @@ export async function runCommand(config, args, chalk) {
|
|
|
281
341
|
receiptId: rcptId,
|
|
282
342
|
action: 'badgr run',
|
|
283
343
|
deploymentId: dep.deployment_id,
|
|
284
|
-
provider: dep.provider,
|
|
285
344
|
gpu: dep.gpu_type,
|
|
286
345
|
status: dep.status,
|
|
287
346
|
createdAt: new Date().toISOString(),
|
|
288
347
|
});
|
|
289
348
|
|
|
290
|
-
console.log(
|
|
349
|
+
console.log();
|
|
350
|
+
console.log(chalk.bold(` Job ID: ${chalk.cyan(dep.deployment_id)}`));
|
|
291
351
|
console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
|
|
292
352
|
if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
|
|
293
353
|
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
@@ -298,16 +358,14 @@ export async function runCommand(config, args, chalk) {
|
|
|
298
358
|
return;
|
|
299
359
|
}
|
|
300
360
|
|
|
301
|
-
// ── Attached mode: stream logs until job completes ────────────────────────
|
|
302
361
|
const ratePerHour = dep.cost_per_hour || 0;
|
|
303
|
-
console.log(chalk.dim('\n ──
|
|
362
|
+
console.log(chalk.dim('\n ── Streaming logs (Ctrl+C to stop job) ─────────────────────────\n'));
|
|
304
363
|
|
|
305
|
-
// Teardown helper — called on SIGINT, max-runtime, max-cost, or heartbeat loss.
|
|
306
364
|
async function teardown(reason) {
|
|
307
365
|
const labels = {
|
|
308
366
|
'max-runtime': chalk.yellow('\n ⏱ Max runtime reached — stopping job...'),
|
|
309
367
|
'max-cost': chalk.yellow('\n 💰 Spend cap reached — stopping job...'),
|
|
310
|
-
'heartbeat-lost': chalk.red('\n ✗
|
|
368
|
+
'heartbeat-lost': chalk.red('\n ✗ No response from machine — stopping job...'),
|
|
311
369
|
'interrupted': chalk.yellow('\n Stopping job...'),
|
|
312
370
|
};
|
|
313
371
|
console.log(labels[reason] ?? chalk.yellow('\n Stopping job...'));
|
|
@@ -318,8 +376,7 @@ export async function runCommand(config, args, chalk) {
|
|
|
318
376
|
}
|
|
319
377
|
const runtimeMs = Date.now() - attachStart;
|
|
320
378
|
const finalCost = ratePerHour * (runtimeMs / 3_600_000);
|
|
321
|
-
|
|
322
|
-
updateReceipt(rcptId, { status: reason, runtimeSeconds: Math.round(runtimeMs / 1000), finalCost, failureType });
|
|
379
|
+
updateReceipt(rcptId, { status: reason, runtimeSeconds: Math.round(runtimeMs / 1000), finalCost });
|
|
323
380
|
console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Est. cost: $${finalCost.toFixed(4)}`));
|
|
324
381
|
console.log(chalk.dim(' Job stopped. Billing ended.\n'));
|
|
325
382
|
process.exit(reason === 'interrupted' ? 0 : 1);
|
|
@@ -345,7 +402,6 @@ export async function runCommand(config, args, chalk) {
|
|
|
345
402
|
failureType,
|
|
346
403
|
});
|
|
347
404
|
|
|
348
|
-
// ── Job summary ──────────────────────────────────────────────────────────
|
|
349
405
|
console.log(` ${chalk.bold('Runtime:')} ${fmtRuntime(runtimeMs)}`);
|
|
350
406
|
if (ratePerHour > 0) {
|
|
351
407
|
console.log(` ${chalk.bold('Cost:')} $${finalCost.toFixed(4)} (${chalk.dim(`$${ratePerHour.toFixed(2)}/hr`)})`);
|
|
@@ -353,18 +409,19 @@ export async function runCommand(config, args, chalk) {
|
|
|
353
409
|
if (exitCode !== null && exitCode !== undefined) {
|
|
354
410
|
console.log(` ${chalk.bold('Exit code:')} ${exitCode !== 0 ? chalk.red(exitCode) : chalk.green(exitCode)}`);
|
|
355
411
|
}
|
|
412
|
+
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
356
413
|
|
|
357
414
|
if (finalStatus === 'failed' || (exitCode !== null && exitCode !== 0)) {
|
|
358
|
-
const label = failureType === 'infrastructure'
|
|
359
|
-
? chalk.red(`\n ✗ Infrastructure failure (${dep.deployment_id}) — not your code\n`)
|
|
360
|
-
: chalk.red(`\n ✗ Job failed (${dep.deployment_id})\n`);
|
|
361
|
-
console.error(label);
|
|
362
415
|
if (failureType === 'infrastructure') {
|
|
363
|
-
console.error(chalk.
|
|
416
|
+
console.error(chalk.red(`\n ✗ Machine failure — this is not your code.\n`));
|
|
417
|
+
console.error(chalk.dim(' Contact support with your receipt ID for a refund.'));
|
|
418
|
+
} else {
|
|
419
|
+
console.error(chalk.red(`\n ✗ Job failed (exit ${exitCode ?? 'unknown'})\n`));
|
|
420
|
+
console.error(chalk.dim(` Check logs: badgr logs ${dep.deployment_id}`));
|
|
364
421
|
}
|
|
365
|
-
console.
|
|
422
|
+
console.log();
|
|
366
423
|
process.exit(exitCode ?? 1);
|
|
367
424
|
} else {
|
|
368
|
-
console.log(chalk.green(`\n ✓
|
|
425
|
+
console.log(chalk.green(`\n ✓ Complete\n`));
|
|
369
426
|
}
|
|
370
427
|
}
|
package/src/commands/serve.js
CHANGED
|
@@ -4,11 +4,10 @@ import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../
|
|
|
4
4
|
import { rankAlternatives, promptFallback } from '../fallback.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
+
* badgr serve meta-llama/Llama-3.1-8B-Instruct
|
|
7
8
|
* badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
* "Endpoint ready", and returns an OpenAI-compatible base URL.
|
|
11
|
-
* Stop billing with `badgr down <id>`.
|
|
10
|
+
* GPU defaults to "AUTO" — backend infers from model size.
|
|
12
11
|
*/
|
|
13
12
|
export function parseServeArgs(args) {
|
|
14
13
|
const flags = {};
|
|
@@ -27,24 +26,21 @@ export function parseServeArgs(args) {
|
|
|
27
26
|
return { model, flags };
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
// Poll GET <endpointUrl>/models until it returns 200 or timeout expires.
|
|
31
|
-
// Returns true if healthy, false if timed out.
|
|
32
29
|
async function waitForEndpoint(endpointUrl, timeoutMs = 5 * 60 * 1000, chalk) {
|
|
33
30
|
const deadline = Date.now() + timeoutMs;
|
|
34
|
-
const modelsUrl = `${endpointUrl}/models`;
|
|
35
31
|
let attempt = 0;
|
|
36
32
|
|
|
37
33
|
while (Date.now() < deadline) {
|
|
38
34
|
attempt++;
|
|
39
35
|
try {
|
|
40
|
-
const res = await fetch(
|
|
36
|
+
const res = await fetch(`${endpointUrl}/models`, { signal: AbortSignal.timeout(8000) });
|
|
41
37
|
if (res.ok) return true;
|
|
42
38
|
} catch {
|
|
43
|
-
//
|
|
39
|
+
// still starting
|
|
44
40
|
}
|
|
45
41
|
const elapsed = Math.round((Date.now() - (deadline - timeoutMs)) / 1000);
|
|
46
42
|
process.stdout.write(
|
|
47
|
-
`\r ${chalk.dim(`
|
|
43
|
+
`\r ${chalk.dim(`Health-checking /v1/models… ${elapsed}s`)} `
|
|
48
44
|
);
|
|
49
45
|
await new Promise(r => setTimeout(r, 8000));
|
|
50
46
|
}
|
|
@@ -56,40 +52,43 @@ export async function serveCommand(config, args, chalk) {
|
|
|
56
52
|
const { model, flags } = parseServeArgs(args);
|
|
57
53
|
|
|
58
54
|
if (!model) {
|
|
59
|
-
console.error(chalk.red('Usage: badgr serve <model>
|
|
60
|
-
console.error(chalk.red(' badgr serve meta-llama/Llama-3.1-8B-Instruct
|
|
55
|
+
console.error(chalk.red('Usage: badgr serve <model>'));
|
|
56
|
+
console.error(chalk.red(' badgr serve meta-llama/Llama-3.1-8B-Instruct'));
|
|
61
57
|
return;
|
|
62
58
|
}
|
|
63
59
|
|
|
64
60
|
requireApiKey(config);
|
|
65
61
|
|
|
66
|
-
|
|
62
|
+
// gpu=AUTO tells the backend to infer the right GPU from model size
|
|
63
|
+
const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : 'AUTO';
|
|
64
|
+
const gpuLabel = gpu === 'AUTO' ? 'auto' : gpu;
|
|
67
65
|
|
|
68
|
-
console.log(chalk.bold('\
|
|
66
|
+
console.log(chalk.bold('\nServing model\n'));
|
|
69
67
|
console.log(` ${chalk.bold('Model:')} ${model}`);
|
|
70
|
-
console.log(` ${chalk.bold('GPU:')} ${
|
|
68
|
+
console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
|
|
71
69
|
console.log();
|
|
72
|
-
|
|
73
|
-
if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
|
|
74
|
-
console.log(chalk.dim(` API: ${config.baseUrl}`));
|
|
75
|
-
}
|
|
70
|
+
process.stdout.write(chalk.dim(' Finding GPU capacity...\n'));
|
|
76
71
|
|
|
77
72
|
const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
|
|
78
73
|
|
|
74
|
+
function buildBody(gpuOverride, regionOverride) {
|
|
75
|
+
return {
|
|
76
|
+
model,
|
|
77
|
+
gpu: gpuOverride || gpu,
|
|
78
|
+
gpu_count: flags.count || 1,
|
|
79
|
+
...(regionOverride || effectiveRegion ? { region: regionOverride || effectiveRegion } : {}),
|
|
80
|
+
max_price_per_hour: flags.maxPrice,
|
|
81
|
+
name: flags.name,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
79
85
|
let dep;
|
|
80
86
|
try {
|
|
81
87
|
dep = await callApi('/serve', {
|
|
82
88
|
method: 'POST',
|
|
83
89
|
apiKey: config.apiKey,
|
|
84
90
|
baseUrl: config.baseUrl,
|
|
85
|
-
body:
|
|
86
|
-
model,
|
|
87
|
-
gpu: gpu.toUpperCase().replace('-', '_'),
|
|
88
|
-
gpu_count: flags.count || 1,
|
|
89
|
-
...(effectiveRegion ? { region: effectiveRegion } : {}),
|
|
90
|
-
max_price_per_hour: flags.maxPrice,
|
|
91
|
-
name: flags.name,
|
|
92
|
-
},
|
|
91
|
+
body: buildBody(),
|
|
93
92
|
});
|
|
94
93
|
} catch (err) {
|
|
95
94
|
const d = err.errorData;
|
|
@@ -100,68 +99,60 @@ export async function serveCommand(config, args, chalk) {
|
|
|
100
99
|
];
|
|
101
100
|
|
|
102
101
|
if (pool.length === 0) {
|
|
103
|
-
console.error(chalk.red(`\n ✗
|
|
104
|
-
console.error(chalk.dim('
|
|
102
|
+
console.error(chalk.red(`\n ✗ No GPU capacity available right now.\n`));
|
|
103
|
+
console.error(chalk.dim(' Run `badgr capacity` to see alternatives, or try again shortly.'));
|
|
105
104
|
process.exit(1);
|
|
106
105
|
}
|
|
107
106
|
|
|
108
|
-
const ranked = rankAlternatives(
|
|
109
|
-
const chosen = await promptFallback(
|
|
107
|
+
const ranked = rankAlternatives(gpuLabel, pool, 'closest');
|
|
108
|
+
const chosen = await promptFallback(gpuLabel, ranked, chalk);
|
|
110
109
|
|
|
111
110
|
if (!chosen) {
|
|
112
111
|
console.log(chalk.dim('\n Cancelled.\n'));
|
|
113
112
|
process.exit(0);
|
|
114
113
|
}
|
|
115
114
|
|
|
116
|
-
console.log(chalk.dim(`\n
|
|
115
|
+
console.log(chalk.dim(`\n Trying ${chosen.gpu} in ${chosen.region}...\n`));
|
|
117
116
|
|
|
118
117
|
try {
|
|
119
118
|
dep = await callApi('/serve', {
|
|
120
119
|
method: 'POST',
|
|
121
120
|
apiKey: config.apiKey,
|
|
122
121
|
baseUrl: config.baseUrl,
|
|
123
|
-
body:
|
|
124
|
-
model,
|
|
125
|
-
gpu: chosen.gpu.toUpperCase().replace('-', '_'),
|
|
126
|
-
gpu_count: flags.count || 1,
|
|
127
|
-
region: chosen.region,
|
|
128
|
-
max_price_per_hour: flags.maxPrice,
|
|
129
|
-
name: flags.name,
|
|
130
|
-
},
|
|
122
|
+
body: buildBody(chosen.gpu, chosen.region),
|
|
131
123
|
});
|
|
132
124
|
} catch (err2) {
|
|
133
125
|
const d2 = err2.errorData;
|
|
134
126
|
if (d2?.code === 'PROVISIONING_FAILED' || d2?.code === 'PROVIDER_ADAPTER_ERROR') {
|
|
135
|
-
console.error(chalk.red(`\n ✗ Badgr found ${chosen.gpu} capacity
|
|
136
|
-
if (d2.message) console.error(chalk.dim(` Detail: ${d2.message}`));
|
|
127
|
+
console.error(chalk.red(`\n ✗ Badgr found ${chosen.gpu} capacity but could not start the endpoint. Please try again.\n`));
|
|
137
128
|
} else {
|
|
138
|
-
console.error(chalk.red(`\n ✗
|
|
129
|
+
console.error(chalk.red(`\n ✗ Failed to start endpoint on ${chosen.gpu}: ${err2.message}\n`));
|
|
139
130
|
}
|
|
140
|
-
console.error(chalk.dim(
|
|
131
|
+
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
|
|
141
132
|
process.exit(1);
|
|
142
133
|
}
|
|
134
|
+
} else if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
|
|
135
|
+
console.error(chalk.red(`\n ✗ Badgr found capacity but could not start the endpoint. Please try again.\n`));
|
|
136
|
+
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
|
|
137
|
+
process.exit(1);
|
|
143
138
|
} else {
|
|
144
|
-
// Write a failure receipt so every serve attempt is auditable.
|
|
145
139
|
const failRcptId = generateReceiptId();
|
|
146
140
|
addReceipt({
|
|
147
141
|
receiptId: failRcptId,
|
|
148
142
|
action: 'badgr serve',
|
|
149
143
|
model,
|
|
150
|
-
gpu,
|
|
144
|
+
gpu: gpuLabel,
|
|
151
145
|
status: 'failed',
|
|
152
146
|
failureType: 'infrastructure',
|
|
153
147
|
createdAt: new Date().toISOString(),
|
|
154
148
|
});
|
|
155
|
-
console.error(chalk.red(`\n ✗
|
|
149
|
+
console.error(chalk.red(`\n ✗ Could not start endpoint: ${err.message}`));
|
|
156
150
|
console.error(chalk.dim(`\n Receipt: ${failRcptId}`));
|
|
157
|
-
console.error(chalk.dim(`
|
|
158
|
-
console.error(chalk.dim(` Config: badgr config`));
|
|
159
|
-
console.error(chalk.dim(` Docs: badgr --help\n`));
|
|
151
|
+
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
|
|
160
152
|
return;
|
|
161
153
|
}
|
|
162
154
|
}
|
|
163
155
|
|
|
164
|
-
// Mirror to local store so badgr down/status/receipts work offline
|
|
165
156
|
addDeployment({
|
|
166
157
|
id: dep.deployment_id,
|
|
167
158
|
name: dep.name,
|
|
@@ -169,7 +160,6 @@ export async function serveCommand(config, args, chalk) {
|
|
|
169
160
|
model: dep.model || model,
|
|
170
161
|
gpu: dep.gpu_type,
|
|
171
162
|
count: dep.gpu_count,
|
|
172
|
-
provider: dep.provider,
|
|
173
163
|
status: dep.status,
|
|
174
164
|
endpointUrl: dep.endpoint_url || dep.openai_base_url,
|
|
175
165
|
receiptId: dep.receipt_id,
|
|
@@ -182,7 +172,6 @@ export async function serveCommand(config, args, chalk) {
|
|
|
182
172
|
receiptId: rcptId,
|
|
183
173
|
action: 'badgr serve',
|
|
184
174
|
deploymentId: dep.deployment_id,
|
|
185
|
-
provider: dep.provider,
|
|
186
175
|
gpu: dep.gpu_type,
|
|
187
176
|
status: dep.status,
|
|
188
177
|
createdAt: new Date().toISOString(),
|
|
@@ -190,48 +179,41 @@ export async function serveCommand(config, args, chalk) {
|
|
|
190
179
|
|
|
191
180
|
const endpointUrl = dep.endpoint_url || dep.openai_base_url || config.baseUrl;
|
|
192
181
|
|
|
193
|
-
// ── Health check
|
|
182
|
+
// ── Health check ──────────────────────────────────────────────────────────
|
|
194
183
|
let endpointReady = false;
|
|
195
|
-
|
|
196
184
|
if (flags.noWait) {
|
|
197
|
-
console.log(chalk.yellow('\n
|
|
185
|
+
console.log(chalk.yellow('\n Skipped health check (--no-wait)\n'));
|
|
198
186
|
} else {
|
|
199
|
-
console.log(chalk.dim('\n Health-checking endpoint (up to 5 min)...'));
|
|
200
187
|
endpointReady = await waitForEndpoint(endpointUrl, 5 * 60 * 1000, chalk);
|
|
201
188
|
process.stdout.write('\n');
|
|
202
|
-
if (!endpointReady) {
|
|
203
|
-
updateReceipt(rcptId, { status: 'health_check_timeout' });
|
|
204
|
-
}
|
|
189
|
+
if (!endpointReady) updateReceipt(rcptId, { status: 'health_check_timeout' });
|
|
205
190
|
}
|
|
206
191
|
|
|
207
|
-
// ──
|
|
192
|
+
// ── Result ────────────────────────────────────────────────────────────────
|
|
208
193
|
if (endpointReady) {
|
|
209
|
-
console.log(chalk.green('✓ Endpoint ready\n'));
|
|
194
|
+
console.log(chalk.green('\n✓ Endpoint ready\n'));
|
|
210
195
|
} else {
|
|
211
|
-
console.log(chalk.yellow('⏳ Endpoint still starting
|
|
212
|
-
console.log(chalk.dim(' The deployment
|
|
213
|
-
console.log(chalk.dim(
|
|
214
|
-
console.log(chalk.dim(`
|
|
215
|
-
console.log(chalk.dim(` badgr logs ${dep.deployment_id}`));
|
|
216
|
-
console.log(chalk.dim(` curl ${endpointUrl}/models`));
|
|
217
|
-
console.log(chalk.dim(` If it never starts, run: badgr down ${dep.deployment_id}`));
|
|
196
|
+
console.log(chalk.yellow('\n⏳ Endpoint still starting\n'));
|
|
197
|
+
console.log(chalk.dim(' The deployment is provisioned but not yet responding.'));
|
|
198
|
+
console.log(chalk.dim(` Check: badgr logs ${dep.deployment_id}`));
|
|
199
|
+
console.log(chalk.dim(` Stop if it never starts: badgr down ${dep.deployment_id}`));
|
|
218
200
|
console.log();
|
|
219
201
|
}
|
|
220
202
|
|
|
221
|
-
console.log(` ${chalk.bold('
|
|
203
|
+
console.log(` ${chalk.bold('Base URL:')} ${chalk.cyan(endpointUrl)}`);
|
|
222
204
|
console.log(` ${chalk.bold('Model:')} ${dep.model || model}`);
|
|
223
205
|
console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
|
|
224
|
-
console.log(` ${chalk.bold('Endpoint:')} ${chalk.cyan(endpointUrl)}`);
|
|
225
206
|
if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
|
|
226
207
|
console.log(` ${chalk.bold('Logs:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
|
|
227
|
-
console.log(
|
|
208
|
+
console.log(` ${chalk.bold('Stop billing:')} ${chalk.dim(`badgr down ${dep.deployment_id}`)}`);
|
|
209
|
+
console.log();
|
|
228
210
|
|
|
229
211
|
if (endpointReady) {
|
|
230
|
-
|
|
212
|
+
const keySnip = config.apiKey?.slice(0, 8) || 'sk-...';
|
|
213
|
+
console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
|
|
231
214
|
console.log(chalk.dim(` from openai import OpenAI`));
|
|
232
|
-
console.log(chalk.dim(` client = OpenAI(
|
|
215
|
+
console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${keySnip}...")`));
|
|
233
216
|
console.log(chalk.dim(` resp = client.chat.completions.create(model="${dep.model || model}", messages=[...])`));
|
|
217
|
+
console.log();
|
|
234
218
|
}
|
|
235
|
-
|
|
236
|
-
console.log(`\n ${chalk.dim(`Stop billing: badgr down ${dep.deployment_id}`)}\n`);
|
|
237
219
|
}
|