badgr-cli 1.0.42 → 1.0.43
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 +69 -2
- package/package.json +2 -1
- package/src/badgr.js +3 -0
- package/src/commands/comfyui.js +35 -8
- package/src/commands/run.js +14 -33
- package/src/commands/serve.js +43 -32
- package/src/commands/train.js +85 -11
- package/tests/productized-dry-run.test.js +141 -0
- package/tests/serve-lifecycle.test.js +103 -137
- package/tests/template.test.js +11 -13
- package/tests/workload-templates.test.js +37 -2
package/README.md
CHANGED
|
@@ -66,10 +66,14 @@ badgr down <deployment-id>
|
|
|
66
66
|
**Shortcuts** — wrappers around `run` / `serve` for common workloads:
|
|
67
67
|
|
|
68
68
|
| `badgr comfyui run <workflow.json>` | Launch ComfyUI, queue workflow, return endpoint URL |
|
|
69
|
+
| `badgr comfyui batch --workflow ...` | Productized batch image generation — no ComfyUI setup, blessed workflow only |
|
|
69
70
|
| `badgr train <config.yaml>` | LoRA / fine-tuning job, stream logs |
|
|
71
|
+
| `badgr train lora --base-model ...` | Productized LoRA training — preset + dataset, no config file needed |
|
|
70
72
|
| `badgr transcribe <audio>` | Whisper transcription, print transcript |
|
|
71
73
|
| `badgr embed <model> <input>` | Text embeddings, output JSONL |
|
|
72
74
|
|
|
75
|
+
`badgr serve --list-aliases` lists the blessed vLLM model shortcuts (`qwen-7b`, `llama-8b`, `qwen-coder-7b`) that can be used in place of a full model ID.
|
|
76
|
+
|
|
73
77
|
---
|
|
74
78
|
|
|
75
79
|
## `badgr serve` options
|
|
@@ -91,6 +95,9 @@ badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S --region EU
|
|
|
91
95
|
| `--max-cost <$>` | — | Auto-stop when total spend reaches this amount |
|
|
92
96
|
| `--health-path <path>` | auto | Readiness path to poll (auto-detected for ComfyUI → `/system_stats`) |
|
|
93
97
|
| `--no-wait` | — | Skip endpoint health check and return immediately |
|
|
98
|
+
| `--list-aliases` | — | List blessed vLLM model aliases (`qwen-7b`, `llama-8b`, `qwen-coder-7b`) and exit — no provisioning, no API key required |
|
|
99
|
+
|
|
100
|
+
Blessed aliases expand to a full model ID + preset GPU, e.g. `badgr serve qwen-7b` → `Qwen/Qwen2.5-7B-Instruct` on an RTX 4090. Run `badgr serve --list-aliases` to see the current list.
|
|
94
101
|
|
|
95
102
|
---
|
|
96
103
|
|
|
@@ -153,13 +160,39 @@ Requires either `--max-cost` or `--persistent` to prevent runaway billing.
|
|
|
153
160
|
|
|
154
161
|
---
|
|
155
162
|
|
|
163
|
+
## `badgr comfyui batch` options
|
|
164
|
+
|
|
165
|
+
Productized batch image generation — runs a list of prompts through a **blessed** ComfyUI workflow and returns image URLs. No ComfyUI setup, no workflow file, no manual teardown.
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
badgr comfyui batch --workflow sdxl-basic --prompts prompts.txt --max-cost 10
|
|
169
|
+
badgr comfyui batch --workflow sdxl-basic --prompt "a cat on a beach" --prompt "a dog in the park" --max-cost 5
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Blessed workflows: `sdxl-basic` (SDXL text-to-image, default sampler settings). Max 20 prompts per batch.
|
|
173
|
+
|
|
174
|
+
| Flag | Default | Description |
|
|
175
|
+
|------|---------|-------------|
|
|
176
|
+
| `--workflow <name>` | — | Blessed workflow ID (required) — currently `sdxl-basic` |
|
|
177
|
+
| `--prompts <file>` | — | Text file, one prompt per line |
|
|
178
|
+
| `--prompt <text>` | — | Inline prompt (repeatable) — combine with `--prompts` if needed |
|
|
179
|
+
| `--max-cost <$>` | — | Auto-stop when total spend reaches this amount (required unless `--dry-run`) |
|
|
180
|
+
| `--max-runtime <min>` | 60 | Auto-stop after N minutes |
|
|
181
|
+
| `--gpu-type <type>` | workflow default | GPU type override |
|
|
182
|
+
| `--tier 1\|2` | 1 | Provider tier |
|
|
183
|
+
| `--dry-run` | — | Preview the batch (workflow, GPU, prompt count, cost) without provisioning |
|
|
184
|
+
|
|
185
|
+
Polls until complete and prints image URLs, or detaches with `badgr status` guidance if it outlives `--max-runtime`.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
156
189
|
## `badgr train` options
|
|
157
190
|
|
|
158
191
|
```bash
|
|
159
192
|
badgr train config.yaml --gpu A100 --max-runtime 240 --env HF_TOKEN=$HF_TOKEN
|
|
160
193
|
```
|
|
161
194
|
|
|
162
|
-
|
|
195
|
+
Detects framework (axolotl, unsloth, trl) from config content, but **only Axolotl configs run today** — the container command for `unsloth`/`trl`/unrecognized configs isn't wired up yet, so `badgr train` blocks before provisioning rather than billing a GPU that's guaranteed to fail. Use `--framework axolotl` to force it, or use `badgr train lora` for a config-free productized path. Default max-runtime is 120 min.
|
|
163
196
|
|
|
164
197
|
| Flag | Default | Description |
|
|
165
198
|
|------|---------|-------------|
|
|
@@ -169,12 +202,46 @@ Auto-detects framework (axolotl, unsloth, trl) from config content. Default max-
|
|
|
169
202
|
| `--max-price <$/hr>` | — | Hard spend cap per GPU-hour |
|
|
170
203
|
| `--tier 1\|2` | 1 | Provider tier |
|
|
171
204
|
| `--region US\|EU\|AU` | — | Region preference |
|
|
172
|
-
| `--framework <name>` | auto-detect | Force framework: `axolotl`, `unsloth`, `trl` |
|
|
205
|
+
| `--framework <name>` | auto-detect | Force framework: `axolotl`, `unsloth`, `trl` (only `axolotl` currently runs) |
|
|
173
206
|
| `--env KEY=VALUE` | — | Environment variable (repeatable) |
|
|
174
207
|
| `--detach` | — | Launch and return immediately, don't stream logs |
|
|
175
208
|
|
|
176
209
|
---
|
|
177
210
|
|
|
211
|
+
## `badgr train lora` options
|
|
212
|
+
|
|
213
|
+
Productized LoRA training — pass a base model and dataset, no Axolotl config file needed. Badgr generates the config from a preset and returns a downloadable adapter.
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
badgr train lora --base-model mistralai/Mistral-7B-v0.1 --dataset ./train.jsonl --preset small --max-cost 20
|
|
217
|
+
badgr train lora --base-model meta-llama/Llama-3.1-8B-Instruct --dataset https://example.com/data.jsonl --preset medium --max-cost 40
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Dataset sources: local file (uploaded first), direct URL (`https://`, `s3://`), or `--file-id` from a prior `badgr` upload.
|
|
221
|
+
|
|
222
|
+
| Flag | Default | Description |
|
|
223
|
+
|------|---------|-------------|
|
|
224
|
+
| `--base-model <id>` | — | HuggingFace model ID (required) — validated to exist before provisioning |
|
|
225
|
+
| `--dataset <path\|url>` | — | Local file, direct URL, or `s3://` URI |
|
|
226
|
+
| `--file-id <id>` | — | Badgr upload ID instead of `--dataset` |
|
|
227
|
+
| `--preset small\|medium` | `small` | Training profile — see below |
|
|
228
|
+
| `--max-cost <$>` | — | Auto-stop when total spend reaches this amount (required unless `--dry-run`) |
|
|
229
|
+
| `--max-runtime <min>` | 240 | Auto-stop after N minutes |
|
|
230
|
+
| `--gpu-type <type>` | preset default | GPU type override |
|
|
231
|
+
| `--tier 1\|2` | 1 | Provider tier |
|
|
232
|
+
| `--dry-run` | — | Preview the job (preset, GPU, rank, epochs, cost) without provisioning |
|
|
233
|
+
|
|
234
|
+
**Presets:**
|
|
235
|
+
|
|
236
|
+
| Preset | GPU | LoRA rank | Epochs | Best for |
|
|
237
|
+
|--------|-----|-----------|--------|----------|
|
|
238
|
+
| `small` (default) | RTX 4090 | 16 | 3 | Fast, low-cost — good default for most datasets |
|
|
239
|
+
| `medium` | A100 | 32 | 5 | Larger rank/more epochs — bigger datasets or higher quality |
|
|
240
|
+
|
|
241
|
+
On completion, prints an `adapter_url` — download with `GET /v1/jobs/{job_id}/adapter`, or via `badgr workload info` if saved.
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
178
245
|
## `badgr transcribe` options
|
|
179
246
|
|
|
180
247
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "badgr-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.43",
|
|
4
4
|
"description": "Badgr — run or serve GPU workloads from one command",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@inquirer/prompts": "^8.5.2",
|
|
16
|
+
"archiver": "^7.0.1",
|
|
16
17
|
"chalk": "^5.3.0"
|
|
17
18
|
},
|
|
18
19
|
"devDependencies": {
|
package/src/badgr.js
CHANGED
|
@@ -42,12 +42,15 @@ ${chalk.bold('COMMANDS')}
|
|
|
42
42
|
|
|
43
43
|
${chalk.bold('SHORTCUTS')} ${chalk.dim('(wrappers around run / serve for common workloads)')}
|
|
44
44
|
${chalk.cyan('badgr comfyui run <workflow.json>')} Launch ComfyUI, return endpoint URL
|
|
45
|
+
${chalk.cyan('badgr comfyui batch --workflow ...')} Blessed-workflow batch image gen (no ComfyUI setup needed)
|
|
45
46
|
${chalk.cyan('badgr train <config.yaml>')} LoRA / fine-tuning job, stream logs
|
|
47
|
+
${chalk.cyan('badgr train lora --base-model ...')} Productized LoRA training (preset + dataset, no config file)
|
|
46
48
|
${chalk.cyan('badgr transcribe <audio>')} Whisper transcription, print transcript
|
|
47
49
|
${chalk.cyan('badgr embed <model> <input>')} Text embeddings, output JSONL
|
|
48
50
|
${chalk.cyan('badgr serve template <name>')} Launch an endpoint template (vllm, invokeai, comfyui, …)
|
|
49
51
|
${chalk.cyan('badgr run template <name>')} Launch a job template (axolotl, unsloth)
|
|
50
52
|
${chalk.cyan('badgr template list')} Browse all pre-built templates
|
|
53
|
+
${chalk.cyan('badgr serve --list-aliases')} List blessed vLLM model shortcuts (qwen-7b, llama-8b, …)
|
|
51
54
|
|
|
52
55
|
${chalk.bold('EXAMPLES')}
|
|
53
56
|
${chalk.dim('# Verify the stack works end-to-end:')}
|
package/src/commands/comfyui.js
CHANGED
|
@@ -11,6 +11,7 @@ import { callApi, listDeployments } from '../api.js';
|
|
|
11
11
|
import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../store.js';
|
|
12
12
|
import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
|
|
13
13
|
import { formatCliError } from '../errors.js';
|
|
14
|
+
import { BLESSED_COMFY_WORKFLOWS } from '../catalog.js';
|
|
14
15
|
|
|
15
16
|
const COMFYUI_IMAGE = process.env.COMFYUI_IMAGE || 'yanwk/comfyui-boot:cu126-megapak';
|
|
16
17
|
const HEALTH_PATH = '/system_stats';
|
|
@@ -126,6 +127,7 @@ export function parseComfyBatchArgs(args) {
|
|
|
126
127
|
if (a === '--max-runtime') { flags.maxRuntime = parseFloat(args[++i]); i++; continue; }
|
|
127
128
|
if (a === '--tier') { flags.tier = args[++i]; i++; continue; }
|
|
128
129
|
if (a === '--gpu-type') { flags.gpuType = args[++i]; i++; continue; }
|
|
130
|
+
if (a === '--dry-run') { flags.dryRun = true; i++; continue; }
|
|
129
131
|
i++;
|
|
130
132
|
}
|
|
131
133
|
return flags;
|
|
@@ -145,14 +147,12 @@ export async function comfyBatchCommand(config, args, chalk) {
|
|
|
145
147
|
return;
|
|
146
148
|
}
|
|
147
149
|
|
|
148
|
-
if (!flags.maxCost) {
|
|
150
|
+
if (!flags.maxCost && !flags.dryRun) {
|
|
149
151
|
console.error(chalk.red('\n ✗ --max-cost is required.\n'));
|
|
150
152
|
process.exitCode = 1;
|
|
151
153
|
return;
|
|
152
154
|
}
|
|
153
155
|
|
|
154
|
-
requireApiKey(config);
|
|
155
|
-
|
|
156
156
|
// Collect prompts from file or --prompt flags
|
|
157
157
|
let prompts = flags.inlinePrompts || [];
|
|
158
158
|
if (flags.prompts) {
|
|
@@ -168,6 +168,23 @@ export async function comfyBatchCommand(config, args, chalk) {
|
|
|
168
168
|
prompts = prompts.concat(lines);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
if (flags.dryRun) {
|
|
172
|
+
const workflowSpec = BLESSED_COMFY_WORKFLOWS[flags.workflow];
|
|
173
|
+
console.log(chalk.bold('\n⚡ Dry run — no GPU will be provisioned\n'));
|
|
174
|
+
console.log(` ${chalk.bold('Workflow:')} ${flags.workflow}${workflowSpec ? '' : chalk.yellow(' (unknown — server will reject this)')}`);
|
|
175
|
+
if (workflowSpec) {
|
|
176
|
+
console.log(` ${chalk.bold('GPU:')} ${flags.gpuType || workflowSpec.gpu_type}`);
|
|
177
|
+
console.log(` ${chalk.dim(workflowSpec.description)}`);
|
|
178
|
+
}
|
|
179
|
+
console.log(` ${chalk.bold('Prompts:')} ${prompts.length}${prompts.length > 20 ? chalk.yellow(' (exceeds the 20-prompt limit — server will reject this)') : ''}`);
|
|
180
|
+
if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost}`);
|
|
181
|
+
console.log(` ${chalk.bold('Max runtime:')} ${flags.maxRuntime ?? 60}min`);
|
|
182
|
+
console.log(chalk.dim('\n Remove --dry-run to submit.\n'));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
requireApiKey(config);
|
|
187
|
+
|
|
171
188
|
if (prompts.length === 0) {
|
|
172
189
|
console.error(chalk.red('\n ✗ No prompts provided. Use --prompts file.txt or --prompt "text"\n'));
|
|
173
190
|
process.exitCode = 1;
|
|
@@ -194,10 +211,15 @@ export async function comfyBatchCommand(config, args, chalk) {
|
|
|
194
211
|
|
|
195
212
|
let job;
|
|
196
213
|
try {
|
|
197
|
-
job = await callApi(
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
214
|
+
job = await callApi('/jobs', {
|
|
215
|
+
method: 'POST',
|
|
216
|
+
apiKey: config.apiKey,
|
|
217
|
+
baseUrl: config.baseUrl,
|
|
218
|
+
body: {
|
|
219
|
+
type: 'comfy.batch',
|
|
220
|
+
input,
|
|
221
|
+
policy: { max_cost: flags.maxCost, max_runtime_minutes: maxRuntime, tier: flags.tier },
|
|
222
|
+
},
|
|
201
223
|
});
|
|
202
224
|
} catch (err) {
|
|
203
225
|
console.error(chalk.red(`\n ✗ Failed to submit job: ${err.message}\n`));
|
|
@@ -215,7 +237,12 @@ export async function comfyBatchCommand(config, args, chalk) {
|
|
|
215
237
|
while (Date.now() - startMs < maxMs) {
|
|
216
238
|
await new Promise(r => setTimeout(r, 15_000));
|
|
217
239
|
let detail;
|
|
218
|
-
try {
|
|
240
|
+
try {
|
|
241
|
+
detail = await callApi(`/jobs/${job.job_id}`, {
|
|
242
|
+
apiKey: config.apiKey,
|
|
243
|
+
baseUrl: config.baseUrl,
|
|
244
|
+
});
|
|
245
|
+
} catch { continue; }
|
|
219
246
|
process.stdout.write(`\r Status: ${detail.status} elapsed: ${Math.floor((Date.now() - startMs) / 1000)}s `);
|
|
220
247
|
if (detail.status === 'completed') {
|
|
221
248
|
const out = detail.output || {};
|
package/src/commands/run.js
CHANGED
|
@@ -383,11 +383,10 @@ function _isGitHubUrl(arg) {
|
|
|
383
383
|
|
|
384
384
|
/**
|
|
385
385
|
* Zip a local directory into a temp file, returning the temp file path.
|
|
386
|
-
* Uses
|
|
386
|
+
* Uses the `archiver` package so the CLI never depends on a system `zip`
|
|
387
|
+
* binary being present (Windows, minimal containers, etc).
|
|
387
388
|
*/
|
|
388
389
|
async function _zipDirectory(dirPath, chalk) {
|
|
389
|
-
// Use archiver if available, otherwise fall back to a manual approach via
|
|
390
|
-
// a child process calling `zip` (available on Linux/macOS) or PowerShell on Windows.
|
|
391
390
|
const absDir = path.resolve(dirPath);
|
|
392
391
|
if (!fs.existsSync(absDir)) {
|
|
393
392
|
throw new Error(`Directory not found: ${absDir}`);
|
|
@@ -395,37 +394,19 @@ async function _zipDirectory(dirPath, chalk) {
|
|
|
395
394
|
|
|
396
395
|
const tmpFile = path.join(os.tmpdir(), `badgr-upload-${Date.now()}.zip`);
|
|
397
396
|
|
|
398
|
-
|
|
399
|
-
try {
|
|
400
|
-
const { default: archiver } = await import('archiver');
|
|
401
|
-
await new Promise((resolve, reject) => {
|
|
402
|
-
const output = createWriteStream(tmpFile);
|
|
403
|
-
const archive = archiver('zip', { zlib: { level: 6 } });
|
|
404
|
-
output.on('close', resolve);
|
|
405
|
-
archive.on('error', reject);
|
|
406
|
-
archive.pipe(output);
|
|
407
|
-
archive.glob('**/*', {
|
|
408
|
-
cwd: absDir,
|
|
409
|
-
dot: false,
|
|
410
|
-
ignore: [..._ZIP_EXCLUDES].map(e => `**/${e}/**`).concat([..._ZIP_EXCLUDES].map(e => e)),
|
|
411
|
-
});
|
|
412
|
-
archive.finalize();
|
|
413
|
-
});
|
|
414
|
-
return tmpFile;
|
|
415
|
-
} catch {
|
|
416
|
-
// archiver not installed — fall back to system zip command
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
const { spawn } = await import('child_process');
|
|
397
|
+
const { default: archiver } = await import('archiver');
|
|
420
398
|
await new Promise((resolve, reject) => {
|
|
421
|
-
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
399
|
+
const output = createWriteStream(tmpFile);
|
|
400
|
+
const archive = archiver('zip', { zlib: { level: 6 } });
|
|
401
|
+
output.on('close', resolve);
|
|
402
|
+
archive.on('error', reject);
|
|
403
|
+
archive.pipe(output);
|
|
404
|
+
archive.glob('**/*', {
|
|
405
|
+
cwd: absDir,
|
|
406
|
+
dot: false,
|
|
407
|
+
ignore: [..._ZIP_EXCLUDES].map(e => `**/${e}/**`).concat([..._ZIP_EXCLUDES].map(e => e)),
|
|
408
|
+
});
|
|
409
|
+
archive.finalize();
|
|
429
410
|
});
|
|
430
411
|
|
|
431
412
|
return tmpFile;
|
package/src/commands/serve.js
CHANGED
|
@@ -42,6 +42,7 @@ export function parseServeArgs(args) {
|
|
|
42
42
|
if (args[i] === '--runtime') { flags.runtime = args[++i]; i++; continue; }
|
|
43
43
|
if (args[i] === '--hf-repo') { flags.hfRepo = args[++i]; i++; continue; }
|
|
44
44
|
if (args[i] === '--hf-file') { flags.hfFile = args[++i]; i++; continue; }
|
|
45
|
+
if (args[i] === '--list-aliases') { flags.listAliases = true; i++; continue; }
|
|
45
46
|
if (args[i] === '--env') {
|
|
46
47
|
const kv = args[++i]; i++;
|
|
47
48
|
if (!flags.env) flags.env = [];
|
|
@@ -97,20 +98,17 @@ function _inferGgufProfile(ggufPath) {
|
|
|
97
98
|
return { label: 'GGUF inference (36B+, llama.cpp)', vram: '48+ GB', gpus: ['A6000', 'L40S', 'A100'] };
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return 'Waiting for
|
|
101
|
+
// Label derived from the backend's real readiness signal (readiness_reason),
|
|
102
|
+
// not a fixed timer — the timer only breaks ties when we have no signal yet.
|
|
103
|
+
function _serveStageLabel(elapsedSec, healthPath, readinessReason) {
|
|
104
|
+
switch (readinessReason) {
|
|
105
|
+
case 'port_unreachable': return 'Waiting for container to start…';
|
|
106
|
+
case 'http_404': return 'Model loading — waiting for the API to come up…';
|
|
107
|
+
case 'http_error': return `Waiting for ${healthPath || 'endpoint'} (non-200 response)…`;
|
|
108
|
+
default: break;
|
|
105
109
|
}
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
if (elapsedSec < 180) return 'Downloading model…';
|
|
109
|
-
return 'Waiting for /health…';
|
|
110
|
-
}
|
|
111
|
-
if (elapsedSec < 30) return 'Starting container…';
|
|
112
|
-
if (elapsedSec < 120) return 'Container starting…';
|
|
113
|
-
return `Waiting for ${healthPath}…`;
|
|
110
|
+
if (elapsedSec < 30) return 'Starting container…';
|
|
111
|
+
return `Waiting for ${healthPath || 'endpoint'}…`;
|
|
114
112
|
}
|
|
115
113
|
|
|
116
114
|
function _detectHealthPath(image) {
|
|
@@ -121,16 +119,21 @@ function _detectHealthPath(image) {
|
|
|
121
119
|
}
|
|
122
120
|
|
|
123
121
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
122
|
+
* Wait for the deployment's app-level endpoint to become ready by polling Badgr's
|
|
123
|
+
* own deployment status (GET /deployments/{id}) — never the RunPod proxy/pod
|
|
124
|
+
* directly. The backend does the real health_path probing (see
|
|
125
|
+
* DeploymentService.check_endpoint_readiness); a pod reporting RUNNING only
|
|
126
|
+
* means infrastructure is up, not that the app inside is serving.
|
|
126
127
|
* Returns { ready: boolean, timedOut: boolean, depFailed: boolean, failReason?: string }
|
|
127
128
|
*/
|
|
128
|
-
|
|
129
|
+
// vLLM cold start (model download + load) often exceeds 5 min on first boot.
|
|
130
|
+
const VLLM_SERVE_WAIT_MS = 15 * 60 * 1000;
|
|
131
|
+
|
|
132
|
+
async function waitForEndpoint(deploymentId, config, timeoutMs = VLLM_SERVE_WAIT_MS, chalk, healthPath = '/models') {
|
|
129
133
|
const startMs = Date.now();
|
|
130
134
|
const deadline = startMs + timeoutMs;
|
|
131
135
|
|
|
132
136
|
while (Date.now() < deadline) {
|
|
133
|
-
// Check deployment status first — fail fast on OOM/crash before waiting more
|
|
134
137
|
try {
|
|
135
138
|
const dep = await callApi(`/deployments/${deploymentId}`, {
|
|
136
139
|
apiKey: config.apiKey,
|
|
@@ -139,23 +142,19 @@ async function waitForEndpoint(endpointUrl, deploymentId, config, timeoutMs = 5
|
|
|
139
142
|
});
|
|
140
143
|
if (['failed', 'terminated', 'error', 'stopped'].includes(dep.status)) {
|
|
141
144
|
process.stdout.write('\n');
|
|
142
|
-
return { ready: false, timedOut: false, depFailed: true, failReason: dep.error || dep.status };
|
|
145
|
+
return { ready: false, timedOut: false, depFailed: true, failReason: dep.fix_hint || dep.error || dep.status };
|
|
143
146
|
}
|
|
147
|
+
if (dep.endpoint_ready) {
|
|
148
|
+
process.stdout.write('\n');
|
|
149
|
+
return { ready: true, timedOut: false, depFailed: false };
|
|
150
|
+
}
|
|
151
|
+
const elapsed = Math.round((Date.now() - startMs) / 1000);
|
|
152
|
+
process.stdout.write(
|
|
153
|
+
`\r ${chalk.dim(_serveStageLabel(elapsed, dep.health_path || healthPath, dep.readiness_reason) + ` (${elapsed}s)`)} `
|
|
154
|
+
);
|
|
144
155
|
} catch {
|
|
145
|
-
// status check failed —
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
try {
|
|
149
|
-
const res = await fetch(`${endpointUrl}${healthPath}`, { signal: AbortSignal.timeout(8000) });
|
|
150
|
-
if (res.ok) { process.stdout.write('\n'); return { ready: true, timedOut: false, depFailed: false }; }
|
|
151
|
-
} catch {
|
|
152
|
-
// still starting
|
|
156
|
+
// status check failed (transient network/API issue) — retry next tick
|
|
153
157
|
}
|
|
154
|
-
|
|
155
|
-
const elapsed = Math.round((Date.now() - startMs) / 1000);
|
|
156
|
-
process.stdout.write(
|
|
157
|
-
`\r ${chalk.dim(_serveStageLabel(elapsed, healthPath) + ` (${elapsed}s)`)} `
|
|
158
|
-
);
|
|
159
158
|
await new Promise(r => setTimeout(r, 8000));
|
|
160
159
|
}
|
|
161
160
|
|
|
@@ -217,6 +216,17 @@ export async function serveCommand(config, args, chalk) {
|
|
|
217
216
|
return serveCommand(config, expandedArgs, chalk);
|
|
218
217
|
}
|
|
219
218
|
|
|
219
|
+
if (args.includes('--list-aliases')) {
|
|
220
|
+
console.log(chalk.bold('\n⚡ Blessed vLLM aliases\n'));
|
|
221
|
+
console.log(chalk.dim(' Use any of these in place of a full model ID: badgr serve <alias>\n'));
|
|
222
|
+
for (const [alias, spec] of Object.entries(BLESSED_VLLM_MODELS)) {
|
|
223
|
+
console.log(` ${chalk.cyan(alias.padEnd(16))} ${spec.model_id}`);
|
|
224
|
+
console.log(` ${''.padEnd(16)} ${chalk.dim(spec.description)}`);
|
|
225
|
+
}
|
|
226
|
+
console.log();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
220
230
|
const { model, flags } = parseServeArgs(args);
|
|
221
231
|
const customImage = flags.image || null;
|
|
222
232
|
const isLlamaCpp = flags.runtime === 'llama.cpp';
|
|
@@ -402,6 +412,7 @@ export async function serveCommand(config, args, chalk) {
|
|
|
402
412
|
tier: tierOverride || effectiveTier,
|
|
403
413
|
...(Object.keys(effectiveEnv).length > 0 ? { env: effectiveEnv } : {}),
|
|
404
414
|
...(flags.maxCost ? { max_cost_usd: flags.maxCost } : {}),
|
|
415
|
+
...(flags.healthPath ? { health_path: flags.healthPath } : {}),
|
|
405
416
|
};
|
|
406
417
|
}
|
|
407
418
|
|
|
@@ -520,7 +531,7 @@ export async function serveCommand(config, args, chalk) {
|
|
|
520
531
|
// status check failed — proceed with endpoint poll anyway
|
|
521
532
|
}
|
|
522
533
|
|
|
523
|
-
const healthResult = await waitForEndpoint(
|
|
534
|
+
const healthResult = await waitForEndpoint(dep.deployment_id, config, VLLM_SERVE_WAIT_MS, chalk, resolvedHealthPath);
|
|
524
535
|
process.stdout.write('\n');
|
|
525
536
|
|
|
526
537
|
if (healthResult.depFailed) {
|
package/src/commands/train.js
CHANGED
|
@@ -21,6 +21,17 @@ const TRAINING_IMAGES = {
|
|
|
21
21
|
generic: 'nvidia/cuda:12.1.0-cudnn8-devel-ubuntu22.04',
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
// Container command per framework — must match what's actually installed in
|
|
25
|
+
// TRAINING_IMAGES[framework]. A framework with no entry here has no known
|
|
26
|
+
// working command and is blocked in trainCommand() rather than guessed at,
|
|
27
|
+
// since a wrong guess still provisions (and bills) the GPU before failing.
|
|
28
|
+
const TRAINING_COMMANDS = {
|
|
29
|
+
axolotl: 'echo "$TRAIN_CONFIG_B64" | base64 -d > /tmp/config.yaml && axolotl train /tmp/config.yaml',
|
|
30
|
+
// huggingface/trl-source ships the `trl` CLI (trl sft|dpo|kto --config <yaml>).
|
|
31
|
+
// We default to `sft` — the common case for a plain base_model+dataset config.
|
|
32
|
+
trl: 'echo "$TRAIN_CONFIG_B64" | base64 -d > /tmp/config.yaml && trl sft --config /tmp/config.yaml',
|
|
33
|
+
};
|
|
34
|
+
|
|
24
35
|
// Preferred GPUs for training: VRAM-heavy workloads.
|
|
25
36
|
const TRAINING_GPUS = ['A100', 'H100', 'L40S', 'A6000'];
|
|
26
37
|
|
|
@@ -104,11 +115,18 @@ export function parseTrainLoraArgs(args) {
|
|
|
104
115
|
if (a === '--max-runtime') { flags.maxRuntime = parseFloat(args[++i]); i++; continue; }
|
|
105
116
|
if (a === '--tier') { flags.tier = args[++i]; i++; continue; }
|
|
106
117
|
if (a === '--gpu-type') { flags.gpuType = args[++i]; i++; continue; }
|
|
118
|
+
if (a === '--dry-run') { flags.dryRun = true; i++; continue; }
|
|
107
119
|
i++;
|
|
108
120
|
}
|
|
109
121
|
return flags;
|
|
110
122
|
}
|
|
111
123
|
|
|
124
|
+
// Mirror of backend LORA_PRESETS (jobs_routes.py) — display only, server is authoritative.
|
|
125
|
+
export const LORA_PRESET_INFO = {
|
|
126
|
+
small: { gpu_type: 'RTX_4090', rank: 16, epochs: 3, description: 'Fast, low-cost — good default for most datasets' },
|
|
127
|
+
medium: { gpu_type: 'A100', rank: 32, epochs: 5, description: 'Larger rank/more epochs — bigger datasets or higher quality' },
|
|
128
|
+
};
|
|
129
|
+
|
|
112
130
|
export async function trainLoraCommand(config, args, chalk) {
|
|
113
131
|
const { callApi } = await import('../api.js');
|
|
114
132
|
const { addReceipt, generateReceiptId } = await import('../store.js');
|
|
@@ -124,12 +142,31 @@ export async function trainLoraCommand(config, args, chalk) {
|
|
|
124
142
|
return;
|
|
125
143
|
}
|
|
126
144
|
|
|
127
|
-
if (!flags.maxCost) {
|
|
145
|
+
if (!flags.maxCost && !flags.dryRun) {
|
|
128
146
|
console.error(chalk.red('\n ✗ --max-cost is required to cap GPU spend.\n'));
|
|
129
147
|
process.exitCode = 1;
|
|
130
148
|
return;
|
|
131
149
|
}
|
|
132
150
|
|
|
151
|
+
if (flags.dryRun) {
|
|
152
|
+
const preset = flags.preset || 'small';
|
|
153
|
+
const presetInfo = LORA_PRESET_INFO[preset];
|
|
154
|
+
console.log(chalk.bold('\n⚡ Dry run — no GPU will be provisioned\n'));
|
|
155
|
+
console.log(` ${chalk.bold('Base model:')} ${flags.baseModel}`);
|
|
156
|
+
console.log(` ${chalk.bold('Dataset:')} ${flags.fileId || flags.dataset || chalk.dim('(none given)')}`);
|
|
157
|
+
console.log(` ${chalk.bold('Preset:')} ${preset}${presetInfo ? '' : chalk.yellow(' (unknown — server will reject this)')}`);
|
|
158
|
+
if (presetInfo) {
|
|
159
|
+
console.log(` ${chalk.bold('GPU:')} ${flags.gpuType || presetInfo.gpu_type}`);
|
|
160
|
+
console.log(` ${chalk.bold('LoRA rank:')} ${presetInfo.rank}`);
|
|
161
|
+
console.log(` ${chalk.bold('Epochs:')} ${presetInfo.epochs}`);
|
|
162
|
+
console.log(` ${chalk.dim(presetInfo.description)}`);
|
|
163
|
+
}
|
|
164
|
+
if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost}`);
|
|
165
|
+
console.log(` ${chalk.bold('Max runtime:')} ${flags.maxRuntime ?? 240}min`);
|
|
166
|
+
console.log(chalk.dim('\n Remove --dry-run to submit (local file datasets are uploaded first).\n'));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
133
170
|
requireApiKey(config);
|
|
134
171
|
|
|
135
172
|
// Build dataset input field
|
|
@@ -147,11 +184,23 @@ export async function trainLoraCommand(config, args, chalk) {
|
|
|
147
184
|
return;
|
|
148
185
|
}
|
|
149
186
|
console.log(chalk.dim(`\n Uploading dataset ${flags.dataset}…`));
|
|
150
|
-
const
|
|
151
|
-
const
|
|
187
|
+
const { readFileSync: readDs } = await import('fs');
|
|
188
|
+
const fileData = readDs(flags.dataset);
|
|
152
189
|
const form = new FormData();
|
|
153
|
-
form.
|
|
154
|
-
const
|
|
190
|
+
form.append('file', new Blob([fileData]), flags.dataset.split('/').pop() || 'dataset.jsonl');
|
|
191
|
+
const baseUrl = config.baseUrl.replace(/\/v1\/?$/, '');
|
|
192
|
+
const uploadRes = await fetch(`${baseUrl}/v1/uploads`, {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
body: form,
|
|
195
|
+
headers: { Authorization: `Bearer ${config.apiKey}` },
|
|
196
|
+
});
|
|
197
|
+
if (!uploadRes.ok) {
|
|
198
|
+
const text = await uploadRes.text().catch(() => '');
|
|
199
|
+
console.error(chalk.red(`\n ✗ Dataset upload failed: ${uploadRes.status}${text ? ` — ${text}` : ''}\n`));
|
|
200
|
+
process.exitCode = 1;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const uploadResp = await uploadRes.json();
|
|
155
204
|
input.dataset_file_id = uploadResp.upload_id;
|
|
156
205
|
console.log(chalk.dim(` Uploaded: ${uploadResp.upload_id}`));
|
|
157
206
|
}
|
|
@@ -169,10 +218,15 @@ export async function trainLoraCommand(config, args, chalk) {
|
|
|
169
218
|
|
|
170
219
|
let job;
|
|
171
220
|
try {
|
|
172
|
-
job = await callApi(
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
221
|
+
job = await callApi('/jobs', {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
apiKey: config.apiKey,
|
|
224
|
+
baseUrl: config.baseUrl,
|
|
225
|
+
body: {
|
|
226
|
+
type: 'train.lora',
|
|
227
|
+
input,
|
|
228
|
+
policy: { max_cost: flags.maxCost, max_runtime_minutes: maxRuntime, tier: flags.tier },
|
|
229
|
+
},
|
|
176
230
|
});
|
|
177
231
|
} catch (err) {
|
|
178
232
|
console.error(chalk.red(`\n ✗ Failed to submit job: ${err.message}\n`));
|
|
@@ -191,7 +245,12 @@ export async function trainLoraCommand(config, args, chalk) {
|
|
|
191
245
|
while (Date.now() - startMs < maxMs) {
|
|
192
246
|
await new Promise(r => setTimeout(r, 15_000));
|
|
193
247
|
let detail;
|
|
194
|
-
try {
|
|
248
|
+
try {
|
|
249
|
+
detail = await callApi(`/jobs/${job.job_id}`, {
|
|
250
|
+
apiKey: config.apiKey,
|
|
251
|
+
baseUrl: config.baseUrl,
|
|
252
|
+
});
|
|
253
|
+
} catch { continue; }
|
|
195
254
|
process.stdout.write(`\r Status: ${detail.status} elapsed: ${Math.floor((Date.now() - startMs) / 1000)}s `);
|
|
196
255
|
if (detail.status === 'completed') {
|
|
197
256
|
const out = detail.output || {};
|
|
@@ -287,12 +346,27 @@ export async function trainCommand(config, args, chalk) {
|
|
|
287
346
|
console.log(` ${chalk.bold('Max runtime:')} ${runtimeLabel}`);
|
|
288
347
|
if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
|
|
289
348
|
console.log();
|
|
349
|
+
|
|
350
|
+
// Block before provisioning: TRAINING_IMAGES[framework] and the container
|
|
351
|
+
// command must be a matched pair, or the job burns GPU time and then fails
|
|
352
|
+
// (e.g. an axolotl `train` binary that doesn't exist in the unsloth image).
|
|
353
|
+
const trainCmd = TRAINING_COMMANDS[framework];
|
|
354
|
+
if (!trainCmd) {
|
|
355
|
+
console.error(chalk.red(` ✗ No runnable command for framework '${framework}' — refusing to provision a GPU that will fail.\n`));
|
|
356
|
+
console.error(chalk.dim(` '${image}' does not have a known working entrypoint for this config yet.`));
|
|
357
|
+
console.error(chalk.dim(` Options:`));
|
|
358
|
+
console.error(chalk.dim(` --framework axolotl force Axolotl (it has native Unsloth-optimization support via config keys)`));
|
|
359
|
+
console.error(chalk.dim(` badgr train lora ... use the productized LoRA path instead\n`));
|
|
360
|
+
process.exitCode = 1;
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
290
364
|
process.stdout.write(chalk.dim(' Finding suitable capacity...\n'));
|
|
291
365
|
|
|
292
366
|
function buildBody(tierOverride) {
|
|
293
367
|
return {
|
|
294
368
|
image,
|
|
295
|
-
command: ['sh', '-c',
|
|
369
|
+
command: ['sh', '-c', trainCmd],
|
|
296
370
|
gpu,
|
|
297
371
|
gpu_count: 1,
|
|
298
372
|
...(flags.region ? { region: flags.region.toUpperCase() } : {}),
|