badgr-cli 1.1.1 → 1.1.2
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/LICENSE +207 -0
- package/README.md +9 -2
- package/package.json +44 -2
- package/src/api.js +16 -0
- package/src/badgr.js +2 -2
- package/src/commands/batch.js +11 -0
- package/src/commands/comfyui.js +31 -15
- package/src/commands/embed.js +13 -10
- package/src/commands/launch.js +8 -1
- package/src/commands/login.js +75 -20
- package/src/commands/run.js +45 -13
- package/src/commands/sbatch.js +6 -1
- package/src/commands/serve.js +44 -30
- package/src/commands/train.js +8 -12
- package/src/commands/transcribe.js +13 -10
- package/src/envFlag.js +10 -0
- package/src/onboarding.js +8 -1
- package/src/progress.js +48 -0
- package/tests/agent-images.test.js +0 -17
- package/tests/api.test.js +0 -168
- package/tests/artifactDownload.test.js +0 -113
- package/tests/artifacts.test.js +0 -168
- package/tests/batch.test.js +0 -641
- package/tests/browser.test.js +0 -51
- package/tests/capacity.test.js +0 -68
- package/tests/commands.test.js +0 -417
- package/tests/config.test.js +0 -96
- package/tests/connect.test.js +0 -83
- package/tests/detect.test.js +0 -191
- package/tests/down.test.js +0 -150
- package/tests/errors.test.js +0 -130
- package/tests/fallback-timeout.test.js +0 -41
- package/tests/fanout.test.js +0 -124
- package/tests/gpu-doctor-classifiers.test.js +0 -402
- package/tests/gpu-doctor-doctor.test.js +0 -304
- package/tests/gpu-doctor-probe-cache.test.js +0 -110
- package/tests/gpu-doctor-probes.test.js +0 -257
- package/tests/heartbeat.test.js +0 -70
- package/tests/job-progress-poll.test.js +0 -136
- package/tests/launch-command-argv.test.js +0 -93
- package/tests/launch-readiness.test.js +0 -403
- package/tests/launch.test.js +0 -440
- package/tests/onboarding.test.js +0 -134
- package/tests/productized-dry-run.test.js +0 -141
- package/tests/productized-runners.test.js +0 -237
- package/tests/pull.test.js +0 -266
- package/tests/rerun.test.js +0 -94
- package/tests/restart.test.js +0 -88
- package/tests/router.test.js +0 -98
- package/tests/run-lifecycle.test.js +0 -1054
- package/tests/sbatch.test.js +0 -190
- package/tests/secrets.test.js +0 -16
- package/tests/serve-apps.test.js +0 -189
- package/tests/serve-lifecycle.test.js +0 -931
- package/tests/slurm.test.js +0 -77
- package/tests/spec.test.js +0 -201
- package/tests/status.test.js +0 -73
- package/tests/store.test.js +0 -187
- package/tests/task.test.js +0 -109
- package/tests/template.test.js +0 -556
- package/tests/train-lora-dataset.test.js +0 -176
- package/tests/upload.test.js +0 -79
- package/tests/workload-rerun.test.js +0 -56
- package/tests/workload-spec.test.js +0 -180
- package/tests/workload-templates.test.js +0 -865
- package/tests/workload-workspace-paths.test.js +0 -46
package/src/commands/login.js
CHANGED
|
@@ -1,36 +1,91 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { DEFAULTS } from '../config.js';
|
|
1
|
+
import { DEFAULTS, loadConfig } from '../config.js';
|
|
3
2
|
import { callApi } from '../api.js';
|
|
3
|
+
import { ensureLoggedIn } from '../onboarding.js';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
const API_KEYS_URL = 'https://aibadgr.com/dashboard/api-keys';
|
|
6
|
+
|
|
7
|
+
function parseArgs(args) {
|
|
8
|
+
const flags = {};
|
|
9
|
+
for (let i = 0; i < args.length; i++) {
|
|
10
|
+
if (args[i] === '--key') flags.key = args[++i];
|
|
11
|
+
}
|
|
12
|
+
return flags;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* badgr login — interactive TTY: if a saved key is already
|
|
17
|
+
* valid, reports that and stops (no browser
|
|
18
|
+
* round-trip). Otherwise opens a browser login
|
|
19
|
+
* link and polls until it completes (same flow
|
|
20
|
+
* `badgr run`/`launch`/`serve`/`comfyui` trigger
|
|
21
|
+
* just-in-time — see onboarding.js's
|
|
22
|
+
* ensureLoggedIn).
|
|
23
|
+
* badgr login --key <key> — non-interactive: paste an existing key directly
|
|
24
|
+
* (CI, scripts, or anyone who already has one).
|
|
25
|
+
*
|
|
26
|
+
* The pasted-key path validates against a live API call before saving —
|
|
27
|
+
* a confirmed-invalid key (401/403) is never written to
|
|
28
|
+
* ~/.badgr/config.json. A network failure during validation still saves
|
|
29
|
+
* the key (with a warning), since that failure says nothing about whether
|
|
30
|
+
* the key itself is valid.
|
|
31
|
+
*/
|
|
32
|
+
export async function loginCommand(chalk, saveConfigFn, args = []) {
|
|
6
33
|
console.log(chalk.bold('\nBadgr Login\n'));
|
|
7
34
|
|
|
8
|
-
const
|
|
9
|
-
message: 'Enter your Badgr API key:',
|
|
10
|
-
validate: v => v.trim() ? true : 'API key is required',
|
|
11
|
-
});
|
|
35
|
+
const flags = parseArgs(args);
|
|
12
36
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
37
|
+
if (!flags.key) {
|
|
38
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
39
|
+
const existing = loadConfig();
|
|
40
|
+
if (existing.apiKey) {
|
|
41
|
+
try {
|
|
42
|
+
await callApi('/models', { apiKey: existing.apiKey, baseUrl: existing.baseUrl });
|
|
43
|
+
console.log(chalk.green('✓ Already logged in'));
|
|
44
|
+
console.log(chalk.dim(` Run ${chalk.cyan('badgr login --key <value>')} to switch accounts.\n`));
|
|
45
|
+
return existing;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err.httpStatus !== 401 && err.httpStatus !== 403) {
|
|
48
|
+
console.log(chalk.yellow(' ⚠ Could not reach the API to confirm the saved key — logging in again.'));
|
|
49
|
+
}
|
|
50
|
+
// Confirmed-invalid or unverifiable — fall through to re-auth below.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
17
53
|
|
|
18
|
-
|
|
19
|
-
|
|
54
|
+
const config = await ensureLoggedIn({ ...DEFAULTS }, chalk);
|
|
55
|
+
console.log(chalk.dim(` Run ${chalk.cyan('badgr run python train.py')} to launch your first job.\n`));
|
|
56
|
+
return config;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.error(chalk.red('\n ✗ --key <value> is required in non-interactive mode.\n'));
|
|
60
|
+
console.error(chalk.dim(` Get an API key: ${API_KEYS_URL}`));
|
|
61
|
+
console.error(chalk.dim(' Example: badgr login --key bdgr_...\n'));
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const apiKey = flags.key.trim();
|
|
67
|
+
if (!apiKey) {
|
|
68
|
+
console.error(chalk.red('\n ✗ API key is required.\n'));
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
20
72
|
|
|
21
|
-
// Verify the key works against the live API
|
|
22
73
|
try {
|
|
23
|
-
await callApi('/models', { apiKey
|
|
24
|
-
console.log(chalk.green('✓ API reachable'));
|
|
25
|
-
console.log(chalk.green('✓ API key valid\n'));
|
|
74
|
+
await callApi('/models', { apiKey, baseUrl: DEFAULTS.baseUrl });
|
|
26
75
|
} catch (err) {
|
|
27
76
|
if (err.httpStatus === 401 || err.httpStatus === 403) {
|
|
28
|
-
console.
|
|
29
|
-
|
|
30
|
-
|
|
77
|
+
console.error(chalk.red('\n ✗ That API key was rejected — not saved.'));
|
|
78
|
+
console.error(chalk.dim(` Get a valid key: ${API_KEYS_URL}\n`));
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
return null;
|
|
31
81
|
}
|
|
82
|
+
console.log(chalk.yellow('\n ⚠ Could not reach the API to verify the key right now — saving anyway.'));
|
|
32
83
|
}
|
|
33
84
|
|
|
85
|
+
const config = saveConfigFn({ apiKey, baseUrl: DEFAULTS.baseUrl });
|
|
86
|
+
|
|
87
|
+
console.log(chalk.green('\n✓ Logged in'));
|
|
88
|
+
console.log(chalk.dim(` Config saved to ~/.badgr/config.json`));
|
|
34
89
|
console.log(chalk.dim(` Run ${chalk.cyan('badgr run python train.py')} to launch your first job.\n`));
|
|
35
90
|
return config;
|
|
36
91
|
}
|
package/src/commands/run.js
CHANGED
|
@@ -8,10 +8,11 @@ import { addReceipt, updateReceipt, generateReceiptId, selectedComputeFromDeploy
|
|
|
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, printFailureClass as _printFailureClass } from '../progress.js';
|
|
11
|
+
import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass, printCapacityPreview, formatTierLabel } from '../progress.js';
|
|
12
12
|
import { detectWorkload, workloadTypeLabel } from '../detect.js';
|
|
13
13
|
import { ensureBadgrReady } from '../onboarding.js';
|
|
14
14
|
import { VM_CLASSES, parseGbSize } from '../spec.js';
|
|
15
|
+
import { parseEnvFlag } from '../envFlag.js';
|
|
15
16
|
|
|
16
17
|
function vmClassLine(sizeKey) {
|
|
17
18
|
const vmClass = VM_CLASSES[sizeKey];
|
|
@@ -98,15 +99,6 @@ export function parseRunArgs(args) {
|
|
|
98
99
|
return { flags, positional, commandArgv };
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
function parseEnvFlag(envList) {
|
|
102
|
-
const obj = {};
|
|
103
|
-
for (const kv of (envList || [])) {
|
|
104
|
-
const idx = kv.indexOf('=');
|
|
105
|
-
if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
|
|
106
|
-
}
|
|
107
|
-
return obj;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
102
|
// Heuristic for --env keys that look like secrets — used to warn (not
|
|
111
103
|
// block) since there is no dashboard --profile injection path yet and
|
|
112
104
|
// --env is currently the only way to get a provider key into a launch VM.
|
|
@@ -458,7 +450,10 @@ async function _zipDirectory(dirPath, chalk) {
|
|
|
458
450
|
throw new Error(`Directory not found: ${absDir}`);
|
|
459
451
|
}
|
|
460
452
|
|
|
461
|
-
|
|
453
|
+
// Random suffix (not just Date.now()) avoids two concurrent zips
|
|
454
|
+
// (e.g. a real upload racing a --dry-run size estimate) colliding on the
|
|
455
|
+
// same filename within the same millisecond.
|
|
456
|
+
const tmpFile = path.join(os.tmpdir(), `badgr-upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.zip`);
|
|
462
457
|
|
|
463
458
|
const { default: archiver } = await import('archiver');
|
|
464
459
|
await new Promise((resolve, reject) => {
|
|
@@ -478,6 +473,18 @@ async function _zipDirectory(dirPath, chalk) {
|
|
|
478
473
|
return tmpFile;
|
|
479
474
|
}
|
|
480
475
|
|
|
476
|
+
// Local-only size estimate for a dry run: zips the project exactly like a
|
|
477
|
+
// real upload would, measures it, then deletes the temp file — no network
|
|
478
|
+
// call, no uploadBlob, so `--dry-run` never actually uploads the project.
|
|
479
|
+
async function _estimateUploadSizeMb(dirPath) {
|
|
480
|
+
const tmpFile = await _zipDirectory(dirPath);
|
|
481
|
+
try {
|
|
482
|
+
return (fs.statSync(tmpFile).size / 1024 / 1024).toFixed(1);
|
|
483
|
+
} finally {
|
|
484
|
+
fs.unlinkSync(tmpFile);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
481
488
|
export async function _uploadCodeZip(config, dirPath, chalk) {
|
|
482
489
|
process.stdout.write(chalk.dim(' Packing project...'));
|
|
483
490
|
const tmpFile = await _zipDirectory(dirPath, chalk);
|
|
@@ -775,6 +782,12 @@ export async function runCommand(config, args, chalk, opts = {}) {
|
|
|
775
782
|
console.log(` ${chalk.bold(isLaunch || flags.noGpu ? 'Compute:' : 'GPU:')} ${isLaunch || flags.noGpu ? 'CPU VM (no GPU)' : (gpu || chalk.dim('auto'))}`);
|
|
776
783
|
if (isLaunch && flags.size) console.log(` ${chalk.bold('VM class:')} ${vmClassLine(flags.size)}`);
|
|
777
784
|
if (isLaunch && quotedRate != null) console.log(` ${chalk.bold('Badgr rate:')} $${quotedRate.toFixed(2)}/hour`);
|
|
785
|
+
if (isLaunch && flags.authRequired?.provider) {
|
|
786
|
+
const authLine = flags.authRequired.status === 'connected'
|
|
787
|
+
? chalk.green(`${flags.authRequired.provider} connected`)
|
|
788
|
+
: chalk.yellow(`${flags.authRequired.provider} not connected — will prompt (or run: badgr connect ${flags.authRequired.provider})`);
|
|
789
|
+
console.log(` ${chalk.bold('Auth:')} ${authLine}`);
|
|
790
|
+
}
|
|
778
791
|
if (flags.minVram) console.log(` ${chalk.bold('Min VRAM:')} ${flags.minVram} GB`);
|
|
779
792
|
if (flags.cpu) console.log(` ${chalk.bold('CPU:')} ${flags.cpu} cores`);
|
|
780
793
|
if (flags.memory) console.log(` ${chalk.bold('Memory:')} ${flags.memory} GB`);
|
|
@@ -787,7 +800,27 @@ export async function runCommand(config, args, chalk, opts = {}) {
|
|
|
787
800
|
if (flags.retrySafe) console.log(` ${chalk.bold('Retry-safe:')} enabled`);
|
|
788
801
|
if (flags.resumeCmd) console.log(` ${chalk.bold('Resume cmd:')} ${flags.resumeCmd}`);
|
|
789
802
|
if (flags.artifacts?.length) console.log(` ${chalk.bold('Artifacts:')} ${flags.artifacts.join(', ')}`);
|
|
790
|
-
console.log(chalk.
|
|
803
|
+
if (!isLaunch) console.log(` ${chalk.bold('Tier:')} ${formatTierLabel(effectiveTier)}`);
|
|
804
|
+
|
|
805
|
+
// Upload-size estimate is GPU-job-specific (spec: "badgr run ... upload
|
|
806
|
+
// size") and does a real local zip pass — skip it for CPU launches
|
|
807
|
+
// (badgr launch always sources from '.'), where it would add a real,
|
|
808
|
+
// possibly-slow filesystem operation to every dry-run preview for no
|
|
809
|
+
// requested benefit.
|
|
810
|
+
if (isLocalPath && !isLaunch) {
|
|
811
|
+
try {
|
|
812
|
+
const sizeMb = await _estimateUploadSizeMb(path.resolve(firstArg));
|
|
813
|
+
console.log(` ${chalk.bold('Upload size:')} ~${sizeMb} MB (not uploaded)`);
|
|
814
|
+
} catch (err) {
|
|
815
|
+
console.log(chalk.dim(` Upload size: could not estimate (${err.message})`));
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
console.log();
|
|
820
|
+
if (!isLaunch && !flags.noGpu) {
|
|
821
|
+
await printCapacityPreview(chalk, config, { gpu, region: flags.region?.toUpperCase(), maxPrice: flags.maxPrice });
|
|
822
|
+
}
|
|
823
|
+
console.log(chalk.dim(' Remove --dry-run to provision.\n'));
|
|
791
824
|
return;
|
|
792
825
|
}
|
|
793
826
|
|
|
@@ -824,7 +857,6 @@ export async function runCommand(config, args, chalk, opts = {}) {
|
|
|
824
857
|
if (flags.artifacts?.length) console.log(` ${chalk.bold('Artifacts:')} ${flags.artifacts.join(', ')}`);
|
|
825
858
|
console.log();
|
|
826
859
|
|
|
827
|
-
|
|
828
860
|
// Resolve --workspace name → ws_… ID before submitting
|
|
829
861
|
let resolvedWorkspaceId = flags.workspace ?? null;
|
|
830
862
|
if (resolvedWorkspaceId && !resolvedWorkspaceId.startsWith('ws_')) {
|
package/src/commands/sbatch.js
CHANGED
|
@@ -22,6 +22,7 @@ import { normalizeGpuType } from '../spec.js';
|
|
|
22
22
|
import { monitorBatchJob, fmtRuntime } from '../batch.js';
|
|
23
23
|
import { runFanOut, DEFAULT_CONCURRENCY } from '../fanout.js';
|
|
24
24
|
import { callApi } from '../api.js';
|
|
25
|
+
import { printCapacityPreview } from '../progress.js';
|
|
25
26
|
|
|
26
27
|
const DEFAULT_IMAGE = 'python:3.11-slim';
|
|
27
28
|
const DEFAULT_MAX_RUNTIME_MIN = 60;
|
|
@@ -153,9 +154,13 @@ export async function sbatchCommand(config, args, chalk) {
|
|
|
153
154
|
const taskIds = isArray ? job.arrayIndices : [null];
|
|
154
155
|
const concurrency = flags.maxConcurrency ?? DEFAULT_CONCURRENCY;
|
|
155
156
|
|
|
157
|
+
const resolvedGpu = job.gpuCount > 0 ? (job.gpuType ? normalizeGpuType(job.gpuType) : 'AUTO') : 'NONE';
|
|
158
|
+
|
|
156
159
|
if (flags.dryRun) {
|
|
160
|
+
requireApiKey(config);
|
|
157
161
|
console.log(chalk.dim(` Dry run — no GPU provisioned. Would submit ${taskIds.length} job(s) (max ${Math.min(concurrency, taskIds.length)} concurrent) with:`));
|
|
158
|
-
console.log(chalk.dim(` image=${opts.image} tier=${opts.tier} max_cost=$${opts.maxCostUsd} max_runtime=${opts.maxRuntimeMinutes}min\n`));
|
|
162
|
+
console.log(chalk.dim(` image=${opts.image} gpu=${resolvedGpu} tier=${opts.tier} max_cost=$${opts.maxCostUsd} max_runtime=${opts.maxRuntimeMinutes}min\n`));
|
|
163
|
+
if (resolvedGpu !== 'NONE') await printCapacityPreview(chalk, config, { gpu: resolvedGpu, region: opts.region?.toUpperCase(), maxPrice: undefined });
|
|
159
164
|
return;
|
|
160
165
|
}
|
|
161
166
|
|
package/src/commands/serve.js
CHANGED
|
@@ -3,8 +3,10 @@ import { callApi, listDeployments } from '../api.js';
|
|
|
3
3
|
import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../store.js';
|
|
4
4
|
import { normalizeTier, callWithFallback } from '../fallback.js';
|
|
5
5
|
import { formatCliError } from '../errors.js';
|
|
6
|
+
import { requireApiKey } from '../config.js';
|
|
7
|
+
import { parseEnvFlag } from '../envFlag.js';
|
|
6
8
|
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, printFailureClass as _printFailureClass } from '../progress.js';
|
|
9
|
+
import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass, printCapacityPreview, formatTierLabel } from '../progress.js';
|
|
8
10
|
|
|
9
11
|
const LLAMA_CPP_IMAGE = 'michaelmanleyx/llama-cpp:server-cuda';
|
|
10
12
|
|
|
@@ -45,6 +47,7 @@ export function parseServeArgs(args) {
|
|
|
45
47
|
if (args[i] === '--hf-repo') { flags.hfRepo = args[++i]; i++; continue; }
|
|
46
48
|
if (args[i] === '--hf-file') { flags.hfFile = args[++i]; i++; continue; }
|
|
47
49
|
if (args[i] === '--list-aliases') { flags.listAliases = true; i++; continue; }
|
|
50
|
+
if (args[i] === '--dry-run') { flags.dryRun = true; i++; continue; }
|
|
48
51
|
if (args[i] === '--env') {
|
|
49
52
|
const kv = args[++i]; i++;
|
|
50
53
|
if (!flags.env) flags.env = [];
|
|
@@ -57,15 +60,6 @@ export function parseServeArgs(args) {
|
|
|
57
60
|
return { model, flags };
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
function parseEnvFlag(envList) {
|
|
61
|
-
const obj = {};
|
|
62
|
-
for (const kv of (envList || [])) {
|
|
63
|
-
const idx = kv.indexOf('=');
|
|
64
|
-
if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
|
|
65
|
-
}
|
|
66
|
-
return obj;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
63
|
function envObjHasHfToken(envList) {
|
|
70
64
|
return (envList || []).some(kv => kv.startsWith('HF_TOKEN='));
|
|
71
65
|
}
|
|
@@ -122,6 +116,17 @@ function _detectHealthPath(image) {
|
|
|
122
116
|
return null;
|
|
123
117
|
}
|
|
124
118
|
|
|
119
|
+
// Priority: explicit --health-path > llama.cpp → /health > task-specific >
|
|
120
|
+
// vLLM → /models > auto-detect custom image > null. Pure (no `dep` needed),
|
|
121
|
+
// so both the dry-run preview and the real post-launch health check use the
|
|
122
|
+
// exact same resolution and can never show a different path than they poll.
|
|
123
|
+
function _resolveHealthPath({ healthPath, isLlamaCpp, customImage, task }) {
|
|
124
|
+
if (healthPath) return healthPath;
|
|
125
|
+
if (isLlamaCpp) return '/health';
|
|
126
|
+
if (!customImage) return (task === 'transcribe' || task === 'image') ? '/health' : '/models';
|
|
127
|
+
return _detectHealthPath(customImage); // '/system_stats' for comfyui, null otherwise
|
|
128
|
+
}
|
|
129
|
+
|
|
125
130
|
/**
|
|
126
131
|
* Wait for the deployment's app-level endpoint to become ready by polling Badgr's
|
|
127
132
|
* own deployment status (GET /deployments/{id}) — never the RunPod proxy/pod
|
|
@@ -211,7 +216,7 @@ const _KNOWN_SERVE_FLAGS = new Set([
|
|
|
211
216
|
'--gpu', '--image', '--task', '--count', '--region', '--tier', '--max-price',
|
|
212
217
|
'--name', '--no-wait', '--max-cost', '--idle-timeout', '--health-path', '--check-nodes',
|
|
213
218
|
'--no-fallback', '--strict-capacity', '--no-expanded-search', '--env',
|
|
214
|
-
'--persistent', '--yes', '-y', '--runtime', '--hf-repo', '--hf-file',
|
|
219
|
+
'--persistent', '--yes', '-y', '--runtime', '--hf-repo', '--hf-file', '--dry-run',
|
|
215
220
|
]);
|
|
216
221
|
|
|
217
222
|
function extractFlag(args, flagName) {
|
|
@@ -396,7 +401,14 @@ export async function serveCommand(config, args, chalk) {
|
|
|
396
401
|
return;
|
|
397
402
|
}
|
|
398
403
|
|
|
399
|
-
|
|
404
|
+
// A dry run previews the plan only and never provisions or spends anything,
|
|
405
|
+
// so it doesn't need the interactive browser login — just a stored key, to
|
|
406
|
+
// keep its existing fail-fast behavior in non-interactive contexts (tests/CI).
|
|
407
|
+
if (flags.dryRun) {
|
|
408
|
+
requireApiKey(config);
|
|
409
|
+
} else {
|
|
410
|
+
config = await ensureBadgrReady(config, chalk);
|
|
411
|
+
}
|
|
400
412
|
|
|
401
413
|
// ── Validate flags early ───────────────────────────────────────────────────
|
|
402
414
|
if (flags.count !== undefined && (!Number.isFinite(flags.count) || flags.count < 1)) {
|
|
@@ -416,7 +428,7 @@ export async function serveCommand(config, args, chalk) {
|
|
|
416
428
|
}
|
|
417
429
|
|
|
418
430
|
// Endpoints bill continuously — require explicit cost control.
|
|
419
|
-
if (!flags.maxCost && !flags.persistent) {
|
|
431
|
+
if (!flags.maxCost && !flags.persistent && !flags.dryRun) {
|
|
420
432
|
const example = model || (customImage ? '--image ...' : '<model>');
|
|
421
433
|
console.error(chalk.red('\n ✗ Endpoints bill continuously until stopped. Specify a spending limit:\n'));
|
|
422
434
|
console.error(chalk.dim(` --max-cost 5 auto-stop when $5 is reached`));
|
|
@@ -469,8 +481,15 @@ export async function serveCommand(config, args, chalk) {
|
|
|
469
481
|
if (flags.gpu) headerLines.push(['GPU', gpuLabel]);
|
|
470
482
|
if (flags.task) headerLines.push(['Task', flags.task]);
|
|
471
483
|
if (flags.env?.length) headerLines.push(['Env', flags.env.join(', ')]);
|
|
484
|
+
if (flags.dryRun) {
|
|
485
|
+
headerLines.push(['Tier', formatTierLabel(effectiveTier)]);
|
|
486
|
+
const previewHealthPath = _resolveHealthPath({ healthPath: flags.healthPath, isLlamaCpp, customImage, task: flags.task });
|
|
487
|
+
headerLines.push(['Health path', previewHealthPath || chalk.dim('none (custom image, unset)')]);
|
|
488
|
+
}
|
|
472
489
|
|
|
473
|
-
console.log(chalk.bold(
|
|
490
|
+
console.log(chalk.bold(flags.dryRun
|
|
491
|
+
? `\n⚡ Dry run — no GPU will be provisioned — ${title}\n`
|
|
492
|
+
: `\n⚡ Serving ${title}\n`));
|
|
474
493
|
const labelWidth = Math.max(...headerLines.map(([label]) => label.length)) + 1;
|
|
475
494
|
for (const [label, value] of headerLines) {
|
|
476
495
|
console.log(` ${chalk.bold(`${label}:`.padEnd(labelWidth + 1))}${value}`);
|
|
@@ -488,6 +507,16 @@ export async function serveCommand(config, args, chalk) {
|
|
|
488
507
|
}
|
|
489
508
|
console.log();
|
|
490
509
|
|
|
510
|
+
if (flags.dryRun) {
|
|
511
|
+
await printCapacityPreview(chalk, config, {
|
|
512
|
+
gpu,
|
|
513
|
+
region: flags.region ? flags.region.toUpperCase() : undefined,
|
|
514
|
+
maxPrice: flags.maxPrice,
|
|
515
|
+
});
|
|
516
|
+
console.log(chalk.dim(' Remove --dry-run to provision.\n'));
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
491
520
|
const STAGE_TOTAL = 5;
|
|
492
521
|
let stageN = 1;
|
|
493
522
|
|
|
@@ -620,22 +649,7 @@ export async function serveCommand(config, args, chalk) {
|
|
|
620
649
|
}
|
|
621
650
|
|
|
622
651
|
// ── Determine health check path ───────────────────────────────────────────
|
|
623
|
-
|
|
624
|
-
let resolvedHealthPath;
|
|
625
|
-
if (flags.healthPath) {
|
|
626
|
-
resolvedHealthPath = flags.healthPath;
|
|
627
|
-
} else if (isLlamaCpp) {
|
|
628
|
-
resolvedHealthPath = '/health';
|
|
629
|
-
} else if (!customImage) {
|
|
630
|
-
// Managed runtimes for transcribe/image expose /health; vLLM (chat, embed) uses /models
|
|
631
|
-
if (flags.task === 'transcribe' || flags.task === 'image') {
|
|
632
|
-
resolvedHealthPath = '/health';
|
|
633
|
-
} else {
|
|
634
|
-
resolvedHealthPath = '/models';
|
|
635
|
-
}
|
|
636
|
-
} else {
|
|
637
|
-
resolvedHealthPath = _detectHealthPath(customImage); // '/system_stats' for comfyui, null otherwise
|
|
638
|
-
}
|
|
652
|
+
const resolvedHealthPath = _resolveHealthPath({ healthPath: flags.healthPath, isLlamaCpp, customImage, task: flags.task });
|
|
639
653
|
|
|
640
654
|
// Gated-model guidance is only shown when it's actually needed — on failure —
|
|
641
655
|
// not up front, so common launches stay short and uncluttered.
|
package/src/commands/train.js
CHANGED
|
@@ -10,8 +10,9 @@ 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
|
+
import { pollJobUntilTerminal, renderJobClosingBlock, printCapacityPreview, formatTierLabel } from '../progress.js';
|
|
14
14
|
import { uploadBlob } from '../api.js';
|
|
15
|
+
import { parseEnvFlag } from '../envFlag.js';
|
|
15
16
|
|
|
16
17
|
const MAX_CONFIG_B = 512 * 1024; // 512 KB config limit
|
|
17
18
|
|
|
@@ -64,15 +65,6 @@ export function parseTrainArgs(args) {
|
|
|
64
65
|
return { configFile: positional[0] || null, flags };
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
function parseEnvFlag(envList) {
|
|
68
|
-
const obj = {};
|
|
69
|
-
for (const kv of (envList || [])) {
|
|
70
|
-
const idx = kv.indexOf('=');
|
|
71
|
-
if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
|
|
72
|
-
}
|
|
73
|
-
return obj;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
68
|
/**
|
|
77
69
|
* Detect training framework from config content.
|
|
78
70
|
* Returns 'axolotl' | 'unsloth' | 'trl' | 'generic'.
|
|
@@ -155,20 +147,24 @@ export async function trainLoraCommand(config, args, chalk) {
|
|
|
155
147
|
if (flags.dryRun) {
|
|
156
148
|
const preset = flags.preset || 'small';
|
|
157
149
|
const presetInfo = LORA_PRESET_INFO[preset];
|
|
150
|
+
const resolvedGpu = flags.gpuType || presetInfo?.gpu_type;
|
|
158
151
|
console.log(chalk.bold('\n⚡ Dry run — no GPU will be provisioned\n'));
|
|
159
152
|
console.log(` ${chalk.bold('Base model:')} ${flags.baseModel}`);
|
|
160
153
|
console.log(` ${chalk.bold('Dataset:')} ${flags.fileId || flags.dataset || chalk.dim('(none given)')}`);
|
|
161
154
|
console.log(` ${chalk.bold('Preset:')} ${preset}${presetInfo ? '' : chalk.yellow(' (unknown — server will reject this)')}`);
|
|
162
155
|
if (presetInfo) {
|
|
163
|
-
console.log(` ${chalk.bold('GPU:')} ${
|
|
156
|
+
console.log(` ${chalk.bold('GPU:')} ${resolvedGpu}`);
|
|
164
157
|
console.log(` ${chalk.bold('LoRA rank:')} ${presetInfo.rank}`);
|
|
165
158
|
console.log(` ${chalk.bold('Epochs:')} ${presetInfo.epochs}`);
|
|
166
159
|
console.log(` ${chalk.dim(presetInfo.description)}`);
|
|
167
160
|
}
|
|
161
|
+
console.log(` ${chalk.bold('Tier:')} ${formatTierLabel(normalizeTier(flags.tier))}`);
|
|
168
162
|
if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost}`);
|
|
169
163
|
console.log(` ${chalk.bold('Max runtime:')} ${flags.maxRuntime ?? 240}min`);
|
|
170
164
|
if (flags.resume) console.log(` ${chalk.bold('Resume from:')} ${flags.resume}`);
|
|
171
|
-
console.log(
|
|
165
|
+
console.log();
|
|
166
|
+
if (resolvedGpu) await printCapacityPreview(chalk, config, { gpu: resolvedGpu });
|
|
167
|
+
console.log(chalk.dim(' Remove --dry-run to submit (local file datasets are uploaded first).\n'));
|
|
172
168
|
return;
|
|
173
169
|
}
|
|
174
170
|
|
|
@@ -10,6 +10,8 @@ import { requireApiKey } from '../config.js';
|
|
|
10
10
|
import { addReceipt, generateReceiptId } from '../store.js';
|
|
11
11
|
import { normalizeTier, callWithFallback } from '../fallback.js';
|
|
12
12
|
import { monitorBatchJob, fmtRuntime } from '../batch.js';
|
|
13
|
+
import { parseEnvFlag } from '../envFlag.js';
|
|
14
|
+
import { printCapacityPreview, formatTierLabel } from '../progress.js';
|
|
13
15
|
|
|
14
16
|
const WHISPER_IMAGE = 'fedirz/faster-whisper-server:latest-cuda';
|
|
15
17
|
const DEFAULT_WHISPER_MODEL = 'large-v3';
|
|
@@ -32,6 +34,7 @@ export function parseTranscribeArgs(args) {
|
|
|
32
34
|
if (a === '--language') { flags.language = args[++i]; i++; continue; }
|
|
33
35
|
if (a === '--output') { flags.output = args[++i]; i++; continue; }
|
|
34
36
|
if (a === '--detach') { flags.detach = true; i++; continue; }
|
|
37
|
+
if (a === '--dry-run') { flags.dryRun = true; i++; continue; }
|
|
35
38
|
if (a === '--env') {
|
|
36
39
|
const kv = args[++i]; i++;
|
|
37
40
|
if (!flags.env) flags.env = [];
|
|
@@ -43,15 +46,6 @@ export function parseTranscribeArgs(args) {
|
|
|
43
46
|
return { input: positional[0] || null, flags };
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
function parseEnvFlag(envList) {
|
|
47
|
-
const obj = {};
|
|
48
|
-
for (const kv of (envList || [])) {
|
|
49
|
-
const idx = kv.indexOf('=');
|
|
50
|
-
if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
|
|
51
|
-
}
|
|
52
|
-
return obj;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
49
|
function isUrl(s) {
|
|
56
50
|
return /^(https?|s3|gs|hf):\/\//i.test(s);
|
|
57
51
|
}
|
|
@@ -116,15 +110,24 @@ export async function transcribeCommand(config, args, chalk) {
|
|
|
116
110
|
...(flags.output ? { WHISPER_OUTPUT_FORMAT: flags.output } : {}),
|
|
117
111
|
};
|
|
118
112
|
|
|
119
|
-
console.log(chalk.bold('\n🎙 Transcription\n'));
|
|
113
|
+
console.log(chalk.bold(flags.dryRun ? '\n🎙 Dry run — no GPU will be provisioned — Transcription\n' : '\n🎙 Transcription\n'));
|
|
120
114
|
console.log(` ${chalk.bold('Input:')} ${resolved.inputLabel}`);
|
|
121
115
|
console.log(` ${chalk.bold('Model:')} ${whisperModel}`);
|
|
122
116
|
console.log(` ${chalk.bold('Image:')} ${WHISPER_IMAGE}`);
|
|
123
117
|
console.log(` ${chalk.bold('GPU:')} ${gpu === 'AUTO' ? chalk.dim('auto (8+ GB VRAM)') : gpu}`);
|
|
118
|
+
if (flags.dryRun) console.log(` ${chalk.bold('Tier:')} ${formatTierLabel(effectiveTier)}`);
|
|
124
119
|
console.log(` ${chalk.bold('Max runtime:')} ${maxRuntimeMin}min`);
|
|
125
120
|
if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
|
|
126
121
|
if (flags.language) console.log(` ${chalk.bold('Language:')} ${flags.language}`);
|
|
122
|
+
if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
|
|
127
123
|
console.log();
|
|
124
|
+
|
|
125
|
+
if (flags.dryRun) {
|
|
126
|
+
await printCapacityPreview(chalk, config, { gpu, region: flags.region?.toUpperCase(), maxPrice: flags.maxPrice });
|
|
127
|
+
console.log(chalk.dim(' Remove --dry-run to submit.\n'));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
128
131
|
process.stdout.write(chalk.dim(' Finding suitable capacity...\n'));
|
|
129
132
|
|
|
130
133
|
function buildBody(tierOverride) {
|
package/src/envFlag.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Shared by every command that accepts repeated `--env KEY=VALUE` flags
|
|
2
|
+
// (run, serve, comfyui, train, transcribe, embed).
|
|
3
|
+
export function parseEnvFlag(envList) {
|
|
4
|
+
const obj = {};
|
|
5
|
+
for (const kv of (envList || [])) {
|
|
6
|
+
const idx = kv.indexOf('=');
|
|
7
|
+
if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
|
|
8
|
+
}
|
|
9
|
+
return obj;
|
|
10
|
+
}
|
package/src/onboarding.js
CHANGED
|
@@ -50,7 +50,14 @@ async function pollCliSession(config, sessionId) {
|
|
|
50
50
|
throw new Error('Timed out waiting for login in the browser. Run `badgr login` directly.');
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Opens the browser-based login link and polls until it completes, saving
|
|
55
|
+
* the returned API key. Exported so `badgr login` (an explicit, standalone
|
|
56
|
+
* invocation) can reuse the exact same flow `ensureBadgrReady` triggers
|
|
57
|
+
* just-in-time from `run`/`launch`/`serve`/`comfyui`, rather than having a
|
|
58
|
+
* second, divergent login implementation.
|
|
59
|
+
*/
|
|
60
|
+
export async function ensureLoggedIn(config, chalk) {
|
|
54
61
|
console.log(chalk.dim("\n You're not logged in to Badgr."));
|
|
55
62
|
console.log(chalk.dim(' Badgr uses prepaid credits to pay for the VM and model usage.\n'));
|
|
56
63
|
|
package/src/progress.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// numbered stage lines plus an in-place-redrawing status block, so neither
|
|
3
3
|
// command spams repeated identical loading lines.
|
|
4
4
|
|
|
5
|
+
import { getCapacityPreview } from './api.js';
|
|
6
|
+
|
|
5
7
|
export function stage(n, total, label) {
|
|
6
8
|
return ` [${n}/${total}] ${label}`;
|
|
7
9
|
}
|
|
@@ -200,3 +202,49 @@ export function renderJobClosingBlock(chalk, detail, rcptId) {
|
|
|
200
202
|
|
|
201
203
|
return lines.join('\n') + '\n';
|
|
202
204
|
}
|
|
205
|
+
|
|
206
|
+
// Shared across every dry-run preview that shows routing tier — normalizeTier()
|
|
207
|
+
// already collapses anything else to 1, so this only ever distinguishes 1 vs 2.
|
|
208
|
+
export function formatTierLabel(tier) {
|
|
209
|
+
return tier === 2 ? '2 (marketplace)' : '1 (managed)';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Shared "live capacity preview" block for GPU-provisioning dry runs
|
|
214
|
+
* (badgr serve/run/comfyui/train/batch/sbatch --dry-run). Calls the
|
|
215
|
+
* no-provisioning GET /v1/capacity/suggestions route (api.js's
|
|
216
|
+
* getCapacityPreview) and prints an observed rate/alternatives — same shape
|
|
217
|
+
* `badgr up --dry-run` already showed, now reused everywhere else instead of
|
|
218
|
+
* every dry-run only echoing back the flags the user typed. Best-effort:
|
|
219
|
+
* a failed or empty lookup prints a one-line fallback, never blocks or
|
|
220
|
+
* throws, since a dry run must always finish even if live pricing can't be
|
|
221
|
+
* reached (offline, GPU not currently listed, transient API error, etc).
|
|
222
|
+
*/
|
|
223
|
+
export async function printCapacityPreview(chalk, config, { gpu, region, maxPrice }) {
|
|
224
|
+
if (!gpu || gpu === 'AUTO' || gpu === 'NONE') {
|
|
225
|
+
console.log(chalk.dim(` GPU is auto-selected — resolved to a specific type and priced at provision time.`));
|
|
226
|
+
console.log();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const suggestions = await getCapacityPreview(config, { gpu, region, maxPrice });
|
|
231
|
+
const matches = suggestions?.matches ?? suggestions?.routes ?? [];
|
|
232
|
+
const alternatives = suggestions?.alternatives ?? [];
|
|
233
|
+
console.log(chalk.bold(' Live capacity'));
|
|
234
|
+
if (matches.length === 0) {
|
|
235
|
+
console.log(chalk.yellow(` No ${gpu} capacity found right now`));
|
|
236
|
+
} else {
|
|
237
|
+
const cheapest = matches[0];
|
|
238
|
+
console.log(` ${chalk.cyan(gpu)} available — from ${chalk.green('$' + cheapest.price.toFixed(2) + '/hr')} (${matches.length} offer(s), tier ${cheapest.tier})`);
|
|
239
|
+
}
|
|
240
|
+
if (alternatives.length > 0) {
|
|
241
|
+
alternatives.slice(0, 2).forEach(a => {
|
|
242
|
+
console.log(chalk.dim(` alt: ${a.gpu} in ${a.region} $${a.price.toFixed(2)}/hr${a.diff_desc ? ` — ${a.diff_desc}` : ''}`));
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
console.log();
|
|
246
|
+
} catch (err) {
|
|
247
|
+
console.log(chalk.dim(` Live capacity: could not fetch (${err.message})`));
|
|
248
|
+
console.log();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { readFileSync } from 'fs';
|
|
3
|
-
import { resolve } from 'path';
|
|
4
|
-
|
|
5
|
-
const root = resolve(import.meta.dirname, '../../..');
|
|
6
|
-
|
|
7
|
-
describe('Phase 1 agent runtime images', () => {
|
|
8
|
-
it.each([
|
|
9
|
-
['claude', 'images/badgr-agent-claude/Dockerfile', '@anthropic-ai/claude-code'],
|
|
10
|
-
['codex', 'images/badgr-agent-codex/Dockerfile', '@openai/codex'],
|
|
11
|
-
['opencode', 'images/badgr-agent-opencode/Dockerfile', 'opencode-ai'],
|
|
12
|
-
])('%s image extends the Badgr job runner and installs the CLI', (_name, file, pkg) => {
|
|
13
|
-
const text = readFileSync(resolve(root, file), 'utf8');
|
|
14
|
-
expect(text).toContain('badgr-job-runner');
|
|
15
|
-
expect(text).toContain(pkg);
|
|
16
|
-
});
|
|
17
|
-
});
|