badgr-cli 1.0.44 → 1.0.46
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/README.md +178 -240
- package/package.json +1 -1
- package/src/api.js +18 -0
- package/src/badgr.js +18 -0
- package/src/catalog.js +31 -0
- package/src/commands/comfyui.js +70 -56
- package/src/commands/detect.js +58 -0
- package/src/commands/heartbeat.js +38 -0
- package/src/commands/receipts.js +39 -2
- package/src/commands/restart.js +74 -0
- package/src/commands/run.js +78 -3
- package/src/commands/serve.js +142 -9
- package/src/commands/train.js +22 -27
- package/src/detect.js +362 -0
- package/src/progress.js +160 -0
- package/src/store.js +11 -0
- package/tests/detect.test.js +191 -0
- package/tests/heartbeat.test.js +70 -0
- package/tests/job-progress-poll.test.js +136 -0
- package/tests/productized-runners.test.js +7 -0
- package/tests/restart.test.js +88 -0
- package/tests/run-lifecycle.test.js +111 -1
- package/tests/serve-apps.test.js +189 -0
- package/tests/serve-lifecycle.test.js +93 -0
- package/tests/store.test.js +22 -1
- package/tests/template.test.js +4 -4
- package/tests/workload-templates.test.js +22 -0
package/src/commands/run.js
CHANGED
|
@@ -8,7 +8,8 @@ import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
|
|
|
8
8
|
import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
|
|
9
9
|
import { formatCliError } from '../errors.js';
|
|
10
10
|
import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides } from '../catalog.js';
|
|
11
|
-
import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock } from '../progress.js';
|
|
11
|
+
import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass } from '../progress.js';
|
|
12
|
+
import { detectWorkload, workloadTypeLabel } from '../detect.js';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Flow 1 — local project (primary):
|
|
@@ -56,6 +57,10 @@ export function parseRunArgs(args) {
|
|
|
56
57
|
if (flagArgs[i] === '--save') { flags.save = flagArgs[++i]; i++; continue; }
|
|
57
58
|
if (flagArgs[i] === '--workspace') { flags.workspace = flagArgs[++i]; i++; continue; }
|
|
58
59
|
if (flagArgs[i] === '--cmd') { flags.cmd = flagArgs[++i]; i++; continue; }
|
|
60
|
+
if (flagArgs[i] === '--output') { flags.output = flagArgs[++i]; i++; continue; }
|
|
61
|
+
if (flagArgs[i] === '--checkpoint') { flags.checkpoint = flagArgs[++i]; i++; continue; }
|
|
62
|
+
if (flagArgs[i] === '--retry-safe') { flags.retrySafe = true; i++; continue; }
|
|
63
|
+
if (flagArgs[i] === '--resume-cmd') { flags.resumeCmd = flagArgs[++i]; i++; continue; }
|
|
59
64
|
if (flagArgs[i] === '--env') {
|
|
60
65
|
const kv = flagArgs[++i]; i++;
|
|
61
66
|
if (!flags.env) flags.env = [];
|
|
@@ -339,6 +344,8 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
339
344
|
exitCode,
|
|
340
345
|
runtimeMs: Date.now() - startMs,
|
|
341
346
|
failureType: classifyFailure(status, exitCode),
|
|
347
|
+
failureClass: dep.failure_class ?? null,
|
|
348
|
+
nextAction: dep.next_action ?? null,
|
|
342
349
|
};
|
|
343
350
|
}
|
|
344
351
|
}
|
|
@@ -357,6 +364,7 @@ const _KNOWN_RUN_FLAGS = new Set([
|
|
|
357
364
|
'--detach', '--fallback', '--no-fallback', '--strict-capacity',
|
|
358
365
|
'--no-expanded-search', '--max-runtime', '--max-cost', '--min-vram', '--env',
|
|
359
366
|
'--dry-run', '--cmd', '--save', '--workspace',
|
|
367
|
+
'--output', '--checkpoint', '--retry-safe', '--resume-cmd',
|
|
360
368
|
]);
|
|
361
369
|
|
|
362
370
|
// Directories and files always excluded from project zip uploads.
|
|
@@ -497,10 +505,43 @@ export async function runCommand(config, args, chalk) {
|
|
|
497
505
|
const isGitHubUrl = firstArg && _isGitHubUrl(firstArg);
|
|
498
506
|
const isCodeSource = isLocalPath || isGitHubUrl;
|
|
499
507
|
|
|
508
|
+
// Local paths can be inspected on disk, so a missing --cmd doesn't have to be
|
|
509
|
+
// an error — try to infer it (and output/checkpoint conventions) the same way
|
|
510
|
+
// `badgr detect .` would, before ever provisioning anything.
|
|
511
|
+
let detectionReport = null;
|
|
512
|
+
if (isLocalPath && !flags.cmd) {
|
|
513
|
+
detectionReport = detectWorkload(firstArg);
|
|
514
|
+
if (detectionReport.command && detectionReport.confidence !== 'low') {
|
|
515
|
+
flags.cmd = detectionReport.command;
|
|
516
|
+
console.log(chalk.dim(`\n Detected command (${detectionReport.confidence} confidence): ${detectionReport.command}`));
|
|
517
|
+
console.log(chalk.dim(` Workload: ${workloadTypeLabel(detectionReport.workloadType)} • badgr detect . for the full report • --cmd to override`));
|
|
518
|
+
}
|
|
519
|
+
if (!flags.output && detectionReport.outputs?.length) flags.output = detectionReport.outputs[0];
|
|
520
|
+
if (!flags.checkpoint && detectionReport.checkpoints?.length) flags.checkpoint = detectionReport.checkpoints[0];
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Detection couldn't confidently name a command — ask for just that one
|
|
524
|
+
// missing essential (interactively, if we have a terminal to ask on).
|
|
525
|
+
if (isLocalPath && !flags.cmd && process.stdin.isTTY && process.stdout.isTTY) {
|
|
526
|
+
console.log(chalk.dim(`\n Badgr couldn't confidently detect a command to run in ${path.resolve(firstArg)}.`));
|
|
527
|
+
try {
|
|
528
|
+
const { input } = await import('@inquirer/prompts');
|
|
529
|
+
const answer = await input({
|
|
530
|
+
message: 'Which command should run?',
|
|
531
|
+
default: detectionReport?.command || undefined,
|
|
532
|
+
validate: v => v.trim() ? true : 'A command is required',
|
|
533
|
+
});
|
|
534
|
+
flags.cmd = answer.trim();
|
|
535
|
+
} catch {
|
|
536
|
+
// Ctrl+C or a non-interactive stdin that lied about isTTY — fall through to the hard error below.
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
500
540
|
if (isCodeSource && !flags.cmd) {
|
|
501
541
|
console.error(chalk.red(`\n ✗ --cmd is required when running from a ${isLocalPath ? 'local path' : 'GitHub URL'}.\n`));
|
|
502
542
|
if (isLocalPath) {
|
|
503
543
|
console.error(chalk.dim(' Example: badgr run . --cmd "python train.py" --max-cost 5\n'));
|
|
544
|
+
console.error(chalk.dim(' Or check what Badgr detects first: badgr detect .\n'));
|
|
504
545
|
} else {
|
|
505
546
|
console.error(chalk.dim(' Example: badgr run https://github.com/user/repo --cmd "python train.py" --max-cost 5\n'));
|
|
506
547
|
}
|
|
@@ -550,6 +591,20 @@ export async function runCommand(config, args, chalk) {
|
|
|
550
591
|
return;
|
|
551
592
|
}
|
|
552
593
|
|
|
594
|
+
if (!flags.maxCost && !flags.dryRun && isLocalPath && process.stdin.isTTY && process.stdout.isTTY) {
|
|
595
|
+
try {
|
|
596
|
+
const { input } = await import('@inquirer/prompts');
|
|
597
|
+
const answer = await input({
|
|
598
|
+
message: 'What max cost ($) should cap this run?',
|
|
599
|
+
default: '5',
|
|
600
|
+
validate: v => (Number.isFinite(parseFloat(v)) && parseFloat(v) > 0) ? true : 'Enter a number greater than 0',
|
|
601
|
+
});
|
|
602
|
+
flags.maxCost = parseFloat(answer);
|
|
603
|
+
} catch {
|
|
604
|
+
// fall through to the hard error below
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
553
608
|
if (!flags.maxCost && !flags.dryRun) {
|
|
554
609
|
console.error(chalk.red('\n ✗ --max-cost is required for run workloads.\n'));
|
|
555
610
|
console.error(chalk.dim(' Example: badgr run --gpu RTX_4090 --image node:20 --max-cost 5 -- node script.js\n'));
|
|
@@ -578,7 +633,16 @@ export async function runCommand(config, args, chalk) {
|
|
|
578
633
|
const maxRuntimeMs = effectiveMaxRuntime * 60 * 1000;
|
|
579
634
|
|
|
580
635
|
const maxCost = flags.maxCost ?? null;
|
|
581
|
-
|
|
636
|
+
// BADGR_OUTPUT_DIR / BADGR_CHECKPOINT_DIR / BADGR_RETRY_SAFE are a
|
|
637
|
+
// convention for custom.run containers to write recoverable state outside
|
|
638
|
+
// the pod — Badgr doesn't enforce what the container does with them, it
|
|
639
|
+
// just wires the directories through and surfaces --resume-cmd on failure.
|
|
640
|
+
// Explicit --env always wins if the user passes the same key directly.
|
|
641
|
+
const conventionEnv = {};
|
|
642
|
+
if (flags.output) conventionEnv.BADGR_OUTPUT_DIR = flags.output;
|
|
643
|
+
if (flags.checkpoint) conventionEnv.BADGR_CHECKPOINT_DIR = flags.checkpoint;
|
|
644
|
+
if (flags.retrySafe) conventionEnv.BADGR_RETRY_SAFE = '1';
|
|
645
|
+
const envObj = { ...conventionEnv, ...parseEnvFlag(flags.env) };
|
|
582
646
|
const effectiveTier = normalizeTier(flags.tier);
|
|
583
647
|
|
|
584
648
|
const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : undefined;
|
|
@@ -595,6 +659,10 @@ export async function runCommand(config, args, chalk) {
|
|
|
595
659
|
if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost}`);
|
|
596
660
|
if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice}/hr`);
|
|
597
661
|
if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
|
|
662
|
+
if (flags.output) console.log(` ${chalk.bold('Output:')} ${flags.output}`);
|
|
663
|
+
if (flags.checkpoint) console.log(` ${chalk.bold('Checkpoint:')} ${flags.checkpoint}`);
|
|
664
|
+
if (flags.retrySafe) console.log(` ${chalk.bold('Retry-safe:')} enabled`);
|
|
665
|
+
if (flags.resumeCmd) console.log(` ${chalk.bold('Resume cmd:')} ${flags.resumeCmd}`);
|
|
598
666
|
console.log(chalk.dim('\n Remove --dry-run to provision.\n'));
|
|
599
667
|
return;
|
|
600
668
|
}
|
|
@@ -616,6 +684,9 @@ export async function runCommand(config, args, chalk) {
|
|
|
616
684
|
if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice.toFixed(2)}/hr`);
|
|
617
685
|
if (detach) console.log(` ${chalk.dim('(detached — returns immediately)')}`);
|
|
618
686
|
if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
|
|
687
|
+
if (flags.output) console.log(` ${chalk.bold('Output:')} ${flags.output}`);
|
|
688
|
+
if (flags.checkpoint) console.log(` ${chalk.bold('Checkpoint:')} ${flags.checkpoint}`);
|
|
689
|
+
if (flags.retrySafe) console.log(` ${chalk.bold('Retry-safe:')} enabled`);
|
|
619
690
|
console.log();
|
|
620
691
|
|
|
621
692
|
|
|
@@ -775,6 +846,7 @@ export async function runCommand(config, args, chalk) {
|
|
|
775
846
|
console.log(chalk.dim(_stage(STAGE_TOTAL, STAGE_TOTAL, stageLabels[reason] ?? 'Stopped')));
|
|
776
847
|
console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Estimated cost: ~$${finalCost.toFixed(2)}`));
|
|
777
848
|
_printFinalInfo(chalk, { exitCode: null, teardownOk, jobId: dep.deployment_id, rcptId });
|
|
849
|
+
if (flags.resumeCmd) console.log(` ${chalk.bold('Resume:')} ${flags.resumeCmd}`);
|
|
778
850
|
console.log();
|
|
779
851
|
}
|
|
780
852
|
|
|
@@ -805,6 +877,7 @@ export async function runCommand(config, args, chalk) {
|
|
|
805
877
|
if (dep.status === 'failed') {
|
|
806
878
|
process.removeListener('SIGINT', handleShutdown);
|
|
807
879
|
console.error(formatCliError('JOB_INFRASTRUCTURE_FAILURE', { receiptId: rcptId }, chalk));
|
|
880
|
+
_printFailureClass(chalk, dep);
|
|
808
881
|
process.exitCode = 1;
|
|
809
882
|
return;
|
|
810
883
|
}
|
|
@@ -813,7 +886,7 @@ export async function runCommand(config, args, chalk) {
|
|
|
813
886
|
console.log(chalk.dim(`\n [${stageN}/${STAGE_TOTAL}] Running command (Ctrl+C to stop)`));
|
|
814
887
|
|
|
815
888
|
attachStart = Date.now();
|
|
816
|
-
const { status: finalStatus, exitCode, runtimeMs, failureType } = await attachToJob(config, dep.deployment_id, {
|
|
889
|
+
const { status: finalStatus, exitCode, runtimeMs, failureType, failureClass, nextAction } = await attachToJob(config, dep.deployment_id, {
|
|
817
890
|
chalk,
|
|
818
891
|
maxRuntimeMs,
|
|
819
892
|
maxCost,
|
|
@@ -853,11 +926,13 @@ export async function runCommand(config, args, chalk) {
|
|
|
853
926
|
} else {
|
|
854
927
|
console.error(formatCliError('JOB_FAILED', { exitCode, deploymentId: dep.deployment_id }, chalk));
|
|
855
928
|
}
|
|
929
|
+
_printFailureClass(chalk, { failure_class: failureClass, next_action: nextAction });
|
|
856
930
|
console.log(chalk.dim(_stage(STAGE_TOTAL, STAGE_TOTAL, 'Failed')));
|
|
857
931
|
console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Estimated cost: ~$${finalCost.toFixed(2)}`));
|
|
858
932
|
// The container already reached a terminal state on the provider side by the
|
|
859
933
|
// time we observe it here, so billing is already stopped — no extra teardown call needed.
|
|
860
934
|
_printFinalInfo(chalk, { exitCode, teardownOk: true, jobId: dep.deployment_id, rcptId });
|
|
935
|
+
if (flags.resumeCmd) console.log(` ${chalk.bold('Resume:')} ${flags.resumeCmd}`);
|
|
861
936
|
process.exitCode = exitCode ?? 1;
|
|
862
937
|
return;
|
|
863
938
|
}
|
package/src/commands/serve.js
CHANGED
|
@@ -4,7 +4,7 @@ import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../
|
|
|
4
4
|
import { normalizeTier, callWithFallback } from '../fallback.js';
|
|
5
5
|
import { formatCliError } from '../errors.js';
|
|
6
6
|
import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides, BLESSED_VLLM_MODELS, isLikelyGatedModel } from '../catalog.js';
|
|
7
|
-
import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock } from '../progress.js';
|
|
7
|
+
import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass } from '../progress.js';
|
|
8
8
|
|
|
9
9
|
const LLAMA_CPP_IMAGE = 'michaelmanleyx/llama-cpp:server-cuda';
|
|
10
10
|
|
|
@@ -32,6 +32,7 @@ export function parseServeArgs(args) {
|
|
|
32
32
|
if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
|
|
33
33
|
if (args[i] === '--no-wait') { flags.noWait = true; i++; continue; }
|
|
34
34
|
if (args[i] === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
|
|
35
|
+
if (args[i] === '--idle-timeout') { flags.idleTimeout = parseInt(args[++i], 10); i++; continue; }
|
|
35
36
|
if (args[i] === '--health-path') { flags.healthPath = args[++i]; i++; continue; }
|
|
36
37
|
if (args[i] === '--check-nodes') { flags.checkNodes = args[++i]; i++; continue; }
|
|
37
38
|
// All three aliases map to noMarketplaceFallback
|
|
@@ -150,7 +151,12 @@ async function waitForEndpoint(deploymentId, config, timeoutMs = VLLM_SERVE_WAIT
|
|
|
150
151
|
});
|
|
151
152
|
if (['failed', 'terminated', 'error', 'stopped'].includes(dep.status)) {
|
|
152
153
|
if (blockLines > 0) { _clearBlock(blockLines); blockLines = 0; }
|
|
153
|
-
return {
|
|
154
|
+
return {
|
|
155
|
+
ready: false, timedOut: false, depFailed: true,
|
|
156
|
+
failReason: dep.fix_hint || dep.error || dep.status,
|
|
157
|
+
failureClass: dep.failure_class ?? null,
|
|
158
|
+
nextAction: dep.next_action ?? null,
|
|
159
|
+
};
|
|
154
160
|
}
|
|
155
161
|
if (dep.endpoint_ready) {
|
|
156
162
|
if (blockLines > 0) { _clearBlock(blockLines); blockLines = 0; }
|
|
@@ -203,11 +209,106 @@ async function validateComfyNodes(baseUrl, nodeList, chalk) {
|
|
|
203
209
|
// Known badgr serve flags — used to detect broken shell line continuation.
|
|
204
210
|
const _KNOWN_SERVE_FLAGS = new Set([
|
|
205
211
|
'--gpu', '--image', '--task', '--count', '--region', '--tier', '--max-price',
|
|
206
|
-
'--name', '--no-wait', '--max-cost', '--health-path', '--check-nodes',
|
|
212
|
+
'--name', '--no-wait', '--max-cost', '--idle-timeout', '--health-path', '--check-nodes',
|
|
207
213
|
'--no-fallback', '--strict-capacity', '--no-expanded-search', '--env',
|
|
208
214
|
'--persistent', '--yes', '-y', '--runtime', '--hf-repo', '--hf-file',
|
|
209
215
|
]);
|
|
210
216
|
|
|
217
|
+
function extractFlag(args, flagName) {
|
|
218
|
+
const rest = [];
|
|
219
|
+
let value = null;
|
|
220
|
+
for (let i = 0; i < args.length; i++) {
|
|
221
|
+
if (args[i] === flagName) { value = args[++i]; continue; }
|
|
222
|
+
rest.push(args[i]);
|
|
223
|
+
}
|
|
224
|
+
return { value, rest };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Returns { id, endpointUrl } for a running vLLM endpoint matching modelId, or
|
|
228
|
+
// null. The id is needed so a caller that auto-launches a new one can print a
|
|
229
|
+
// precise `badgr down <id>` hint if something later fails.
|
|
230
|
+
async function findRunningVllmEndpoint(config, modelId) {
|
|
231
|
+
try {
|
|
232
|
+
const existing = await listDeployments(config);
|
|
233
|
+
const ACTIVE = new Set(['running', 'provisioning', 'starting', 'queued']);
|
|
234
|
+
const match = (existing.deployments ?? []).find(d =>
|
|
235
|
+
ACTIVE.has(d.status) && d.workload_type === 'endpoint' && d.model === modelId
|
|
236
|
+
);
|
|
237
|
+
if (!match) return null;
|
|
238
|
+
const endpointUrl = match.endpoint_url || match.openai_base_url || null;
|
|
239
|
+
return endpointUrl ? { id: match.deployment_id, endpointUrl } : null;
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* `badgr serve openwebui [--model <alias-or-id>] [--connect <url>] [flags]`
|
|
247
|
+
*
|
|
248
|
+
* Open WebUI serves a chat UI that connects to a model endpoint — usually
|
|
249
|
+
* vLLM. It is a separate served app from the model itself: this reuses a
|
|
250
|
+
* running vLLM endpoint for the model if one exists, or launches one via
|
|
251
|
+
* `serveCommand` first, then launches Open WebUI wired to it via
|
|
252
|
+
* OPENAI_API_BASE_URL/OPENAI_API_KEY. `--connect` skips vLLM discovery/launch
|
|
253
|
+
* entirely and points Open WebUI at any existing OpenAI-compatible endpoint —
|
|
254
|
+
* no model is ever touched in that mode.
|
|
255
|
+
*/
|
|
256
|
+
async function serveOpenWebUICommand(config, args, chalk) {
|
|
257
|
+
requireApiKey(config);
|
|
258
|
+
const { value: connect, rest: afterConnect } = extractFlag(args, '--connect');
|
|
259
|
+
const { value: model, rest } = extractFlag(afterConnect, '--model');
|
|
260
|
+
|
|
261
|
+
let endpointUrl = connect || null;
|
|
262
|
+
let launchedVllmDepId = null; // set only if we launched a new vLLM (for cleanup hints)
|
|
263
|
+
|
|
264
|
+
if (!endpointUrl) {
|
|
265
|
+
const alias = model || 'qwen-7b';
|
|
266
|
+
const vllmAlias = BLESSED_VLLM_MODELS[alias];
|
|
267
|
+
const modelId = vllmAlias ? vllmAlias.model_id : alias;
|
|
268
|
+
|
|
269
|
+
const existing = await findRunningVllmEndpoint(config, modelId);
|
|
270
|
+
if (existing) {
|
|
271
|
+
endpointUrl = existing.endpointUrl;
|
|
272
|
+
console.log(chalk.dim(` Reusing running vLLM endpoint for ${modelId}: ${endpointUrl}\n`));
|
|
273
|
+
} else {
|
|
274
|
+
// Auto-launching vLLM reuses rest's --max-cost/--persistent, which also
|
|
275
|
+
// apply to Open WebUI below — that's two separately billed deployments,
|
|
276
|
+
// not a shared budget, so say so instead of a silent double spend.
|
|
277
|
+
const { value: webuiMaxCost } = extractFlag(rest, '--max-cost');
|
|
278
|
+
console.log(chalk.bold(`\n Open WebUI needs a model endpoint behind it — no running vLLM endpoint for ${modelId} found.\n`));
|
|
279
|
+
if (webuiMaxCost) {
|
|
280
|
+
console.log(chalk.yellow(` Launching vLLM first with its own --max-cost $${webuiMaxCost} cap (separate from Open WebUI's).`));
|
|
281
|
+
console.log(chalk.yellow(` Total possible spend across both deployments: ~$${(parseFloat(webuiMaxCost) * 2).toFixed(2)}.\n`));
|
|
282
|
+
}
|
|
283
|
+
await serveCommand(config, [alias, ...rest], chalk);
|
|
284
|
+
const launched = await findRunningVllmEndpoint(config, modelId);
|
|
285
|
+
if (!launched) {
|
|
286
|
+
console.error(chalk.red(`\n ✗ vLLM endpoint for ${modelId} did not come up — see output above.\n`));
|
|
287
|
+
console.error(chalk.dim(` Once it's ready, run: badgr serve openwebui --connect <endpoint-url>\n`));
|
|
288
|
+
process.exitCode = 1;
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
endpointUrl = launched.endpointUrl;
|
|
292
|
+
launchedVllmDepId = launched.id;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const overrides = parseTemplateOverrides(rest);
|
|
297
|
+
// User-supplied --env intentionally wins over the auto-wired connection env.
|
|
298
|
+
overrides.env = {
|
|
299
|
+
OPENAI_API_BASE_URL: endpointUrl,
|
|
300
|
+
OPENAI_API_KEY: config.apiKey || 'sk-local',
|
|
301
|
+
...overrides.env,
|
|
302
|
+
};
|
|
303
|
+
const flags = buildTemplateFlags(TEMPLATE_MAP.openwebui, overrides);
|
|
304
|
+
await serveCommand(config, flags, chalk);
|
|
305
|
+
|
|
306
|
+
if (process.exitCode && launchedVllmDepId) {
|
|
307
|
+
console.error(chalk.yellow(`\n Open WebUI failed to start. The vLLM endpoint it was connecting to is still running and billing separately.`));
|
|
308
|
+
console.error(chalk.dim(` Stop it with: badgr down ${launchedVllmDepId}\n`));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
211
312
|
export async function serveCommand(config, args, chalk) {
|
|
212
313
|
// `badgr serve template <name> [flags]` — expand template defaults then re-dispatch
|
|
213
314
|
if (args[0] === 'template') {
|
|
@@ -230,6 +331,12 @@ export async function serveCommand(config, args, chalk) {
|
|
|
230
331
|
return serveCommand(config, expandedArgs, chalk);
|
|
231
332
|
}
|
|
232
333
|
|
|
334
|
+
// `badgr serve openwebui|open-webui [flags]` — a chat UI that connects to a
|
|
335
|
+
// model endpoint (usually vLLM). See serveOpenWebUICommand.
|
|
336
|
+
if (args[0] && ['openwebui', 'open-webui'].includes(args[0].toLowerCase())) {
|
|
337
|
+
return serveOpenWebUICommand(config, args.slice(1), chalk);
|
|
338
|
+
}
|
|
339
|
+
|
|
233
340
|
if (args.includes('--list-aliases')) {
|
|
234
341
|
console.log(chalk.bold('\nTested model routes:\n'));
|
|
235
342
|
for (const alias of Object.keys(BLESSED_VLLM_MODELS)) {
|
|
@@ -437,6 +544,7 @@ export async function serveCommand(config, args, chalk) {
|
|
|
437
544
|
tier: tierOverride || effectiveTier,
|
|
438
545
|
...(Object.keys(effectiveEnv).length > 0 ? { env: effectiveEnv } : {}),
|
|
439
546
|
...(flags.maxCost ? { max_cost_usd: flags.maxCost } : {}),
|
|
547
|
+
...(flags.idleTimeout ? { idle_timeout_minutes: flags.idleTimeout } : {}),
|
|
440
548
|
...(flags.healthPath ? { health_path: flags.healthPath } : {}),
|
|
441
549
|
};
|
|
442
550
|
}
|
|
@@ -536,11 +644,12 @@ export async function serveCommand(config, args, chalk) {
|
|
|
536
644
|
|
|
537
645
|
// Shared reporting for "deployment failed to start" — hit from both the
|
|
538
646
|
// pre-poll status check and the waitForEndpoint poll loop below.
|
|
539
|
-
function reportDeployFailure(failReason) {
|
|
647
|
+
function reportDeployFailure(failReason, failureInfo = {}) {
|
|
540
648
|
console.error(formatCliError('HEALTH_CHECK_DEPLOY_FAILED', {
|
|
541
649
|
deploymentId: dep.deployment_id,
|
|
542
650
|
failReason,
|
|
543
651
|
}, chalk));
|
|
652
|
+
_printFailureClass(chalk, { failure_class: failureInfo.failureClass, next_action: failureInfo.nextAction });
|
|
544
653
|
if (gatedHintNeeded) {
|
|
545
654
|
const rerun = args.join(' ');
|
|
546
655
|
const retryCmd = rerun.includes('HF_TOKEN=') ? rerun : `${rerun} --env HF_TOKEN=$HF_TOKEN`;
|
|
@@ -578,7 +687,9 @@ export async function serveCommand(config, args, chalk) {
|
|
|
578
687
|
timeoutMs: 10_000,
|
|
579
688
|
});
|
|
580
689
|
if (['failed', 'terminated', 'error'].includes(latest.status)) {
|
|
581
|
-
reportDeployFailure(latest.error || latest.status
|
|
690
|
+
reportDeployFailure(latest.error || latest.status, {
|
|
691
|
+
failureClass: latest.failure_class ?? null, nextAction: latest.next_action ?? null,
|
|
692
|
+
});
|
|
582
693
|
return;
|
|
583
694
|
}
|
|
584
695
|
} catch {
|
|
@@ -592,7 +703,9 @@ export async function serveCommand(config, args, chalk) {
|
|
|
592
703
|
);
|
|
593
704
|
|
|
594
705
|
if (healthResult.depFailed) {
|
|
595
|
-
reportDeployFailure(healthResult.failReason
|
|
706
|
+
reportDeployFailure(healthResult.failReason, {
|
|
707
|
+
failureClass: healthResult.failureClass, nextAction: healthResult.nextAction,
|
|
708
|
+
});
|
|
596
709
|
return;
|
|
597
710
|
}
|
|
598
711
|
|
|
@@ -635,22 +748,34 @@ export async function serveCommand(config, args, chalk) {
|
|
|
635
748
|
else if (dep.model || effectiveModel) console.log(` ${chalk.bold('Model:')} ${dep.model || effectiveModel}`);
|
|
636
749
|
if (customImage) console.log(` ${chalk.bold('Image:')} ${customImage}`);
|
|
637
750
|
if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
|
|
751
|
+
if (flags.idleTimeout) console.log(` ${chalk.bold('Idle timeout:')} ${flags.idleTimeout}m (auto-stops if idle — see Heartbeat below)`);
|
|
638
752
|
console.log(` ${chalk.bold('Logs:')} badgr logs ${dep.deployment_id}`);
|
|
753
|
+
console.log(` ${chalk.bold('Restart:')} badgr restart ${dep.deployment_id}`);
|
|
639
754
|
console.log(` ${chalk.bold('Stop billing:')} ${chalk.cyan(`badgr down ${dep.deployment_id}`)}`);
|
|
640
755
|
console.log(` ${chalk.bold('Receipt:')} badgr receipts ${rcptId}`);
|
|
641
756
|
console.log();
|
|
642
757
|
console.log(chalk.dim(' Billing continues until you run: ') + chalk.cyan(`badgr down ${dep.deployment_id}`));
|
|
643
758
|
|
|
644
759
|
if (endpointReady && !customImage) {
|
|
645
|
-
|
|
760
|
+
// dep.endpoint_api_key is a per-endpoint key generated for this deployment
|
|
761
|
+
// (vLLM model serves only) — shown exactly once, here. Falls back to the
|
|
762
|
+
// account-wide key (truncated) for serves that don't get one yet
|
|
763
|
+
// (custom images, managed transcribe/image tasks).
|
|
764
|
+
const hasEndpointKey = Boolean(dep.endpoint_api_key);
|
|
765
|
+
const authKey = hasEndpointKey ? dep.endpoint_api_key : `${config.apiKey?.slice(0, 4) || 'sk-...'}...`;
|
|
646
766
|
const sdkModel = isLlamaCpp ? 'default' : (dep.model || effectiveModel);
|
|
767
|
+
|
|
768
|
+
if (hasEndpointKey) {
|
|
769
|
+
console.log(` ${chalk.bold('API key:')} ${chalk.yellow(dep.endpoint_api_key)}`);
|
|
770
|
+
console.log(chalk.dim(' Shown once — copy it now. This key is scoped to this endpoint only.'));
|
|
771
|
+
}
|
|
647
772
|
console.log(` ${chalk.bold('Test with curl:')}`);
|
|
648
773
|
console.log(chalk.dim(` curl ${endpointUrl}/chat/completions \\`));
|
|
649
|
-
console.log(chalk.dim(` -H "Authorization: Bearer ${
|
|
774
|
+
console.log(chalk.dim(` -H "Authorization: Bearer ${authKey}" -H "Content-Type: application/json" \\`));
|
|
650
775
|
console.log(chalk.dim(` -d '{"model":"${sdkModel}","messages":[{"role":"user","content":"Hello"}]}'`));
|
|
651
776
|
console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
|
|
652
777
|
console.log(chalk.dim(` from openai import OpenAI`));
|
|
653
|
-
console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${
|
|
778
|
+
console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${authKey}")`));
|
|
654
779
|
if (flags.task === 'transcribe') {
|
|
655
780
|
console.log(chalk.dim(` with open("audio.mp3", "rb") as f:`));
|
|
656
781
|
console.log(chalk.dim(` t = client.audio.transcriptions.create(model="${sdkModel}", file=f, response_format="text")`));
|
|
@@ -663,5 +788,13 @@ export async function serveCommand(config, args, chalk) {
|
|
|
663
788
|
console.log(chalk.dim(` resp = client.chat.completions.create(model="${sdkModel}", messages=[{"role": "user", "content": "Hello"}])`));
|
|
664
789
|
}
|
|
665
790
|
console.log();
|
|
791
|
+
if (flags.idleTimeout) {
|
|
792
|
+
console.log(` ${chalk.bold('Heartbeat (required for --idle-timeout):')}`);
|
|
793
|
+
console.log(chalk.dim(` badgr heartbeat ${dep.deployment_id}`));
|
|
794
|
+
console.log(chalk.dim(' Badgr does not proxy your inference traffic, so call this on each real'));
|
|
795
|
+
console.log(chalk.dim(` request (or wire it into your client) — otherwise this endpoint auto-stops`));
|
|
796
|
+
console.log(chalk.dim(` after ${flags.idleTimeout}m even if it's still reachable.`));
|
|
797
|
+
console.log();
|
|
798
|
+
}
|
|
666
799
|
}
|
|
667
800
|
}
|
package/src/commands/train.js
CHANGED
|
@@ -10,6 +10,7 @@ import { requireApiKey } from '../config.js';
|
|
|
10
10
|
import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
|
|
11
11
|
import { normalizeTier, callWithFallback } from '../fallback.js';
|
|
12
12
|
import { monitorBatchJob, fmtRuntime } from '../batch.js';
|
|
13
|
+
import { pollJobUntilTerminal, renderJobClosingBlock } from '../progress.js';
|
|
13
14
|
|
|
14
15
|
const MAX_CONFIG_B = 512 * 1024; // 512 KB config limit
|
|
15
16
|
|
|
@@ -239,35 +240,29 @@ export async function trainLoraCommand(config, args, chalk) {
|
|
|
239
240
|
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
240
241
|
console.log(chalk.dim('\n Polling for completion — Ctrl+C to detach (GPU keeps running)\n'));
|
|
241
242
|
|
|
242
|
-
// Poll until complete
|
|
243
|
-
const startMs = Date.now();
|
|
244
243
|
const maxMs = maxRuntime * 60 * 1000;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
if (
|
|
264
|
-
console.error(chalk.red(`\n\n ✗ Training failed: ${detail.error_code || ''} — ${detail.error_message || ''}\n`));
|
|
265
|
-
process.exitCode = 1;
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
244
|
+
const { outcome, detail } = await pollJobUntilTerminal(callApi, config, job.job_id, { chalk, maxMs });
|
|
245
|
+
|
|
246
|
+
if (outcome === 'polling_failed') {
|
|
247
|
+
console.error(chalk.red(`\n\n ✗ Lost contact with Badgr — could not confirm job status.\n`));
|
|
248
|
+
console.error(chalk.dim(` badgr status\n badgr logs ${job.job_id}\n`));
|
|
249
|
+
process.exitCode = 1;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (outcome === 'timed_out') {
|
|
253
|
+
console.error(chalk.yellow('\n\n Training still running — detached. Check status:\n'));
|
|
254
|
+
console.error(chalk.dim(` badgr status\n`));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (outcome === 'completed') {
|
|
259
|
+
const out = detail.output || {};
|
|
260
|
+
if (out.adapter_url) console.log(`\n ${chalk.bold('Adapter:')} ${out.adapter_url}`);
|
|
261
|
+
if (out.checkpoint_url) console.log(` ${chalk.bold('Checkpoint:')} ${out.checkpoint_url}`);
|
|
262
|
+
if (out.warning) console.log(chalk.yellow(` ${out.warning}`));
|
|
268
263
|
}
|
|
269
|
-
console.
|
|
270
|
-
|
|
264
|
+
console.log(renderJobClosingBlock(chalk, detail, rcptId));
|
|
265
|
+
if (outcome === 'failed') process.exitCode = 1;
|
|
271
266
|
}
|
|
272
267
|
|
|
273
268
|
export async function trainCommand(config, args, chalk) {
|