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 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
- Auto-detects framework (axolotl, unsloth, trl) from config content. Default max-runtime is 120 min.
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.42",
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:')}
@@ -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(config, 'POST', '/v1/jobs', {
198
- type: 'comfy.batch',
199
- input,
200
- policy: { max_cost: flags.maxCost, max_runtime_minutes: maxRuntime, tier: flags.tier },
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 { detail = await callApi(config, 'GET', `/v1/jobs/${job.job_id}`); } catch { continue; }
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 || {};
@@ -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 Node's built-in APIs no external zip library required.
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
- // Attempt to use archiver (optional peer dep) first; fall back to system zip.
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
- // Build exclude args — zip uses -x patterns
422
- const excludeArgs = [];
423
- for (const ex of _ZIP_EXCLUDES) {
424
- excludeArgs.push('-x', `*/${ex}/*`, '-x', `${ex}/*`, '-x', `${ex}`);
425
- }
426
- const child = spawn('zip', ['-r', '-q', tmpFile, '.', ...excludeArgs], { cwd: absDir });
427
- child.on('close', code => code === 0 ? resolve() : reject(new Error(`zip exited ${code}`)));
428
- child.on('error', reject);
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;
@@ -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
- function _serveStageLabel(elapsedSec, healthPath = '/models') {
101
- if (healthPath === '/models') {
102
- if (elapsedSec < 45) return 'Starting vLLM…';
103
- if (elapsedSec < 150) return 'Downloading model…';
104
- return 'Waiting for /v1/models…';
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 (healthPath === '/health') {
107
- if (elapsedSec < 60) return 'Starting server…';
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
- * Poll the model endpoint until /models returns 200, or timeout.
125
- * Also checks deployment status on each iteration fails fast if the dep is dead.
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
- async function waitForEndpoint(endpointUrl, deploymentId, config, timeoutMs = 5 * 60 * 1000, chalk, healthPath = '/models') {
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 — continue to endpoint check
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(endpointUrl, dep.deployment_id, config, 5 * 60 * 1000, chalk, resolvedHealthPath);
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) {
@@ -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 FormData = (await import('formdata-node')).FormData;
151
- const { fileFromPath } = await import('formdata-node/file-from-path');
187
+ const { readFileSync: readDs } = await import('fs');
188
+ const fileData = readDs(flags.dataset);
152
189
  const form = new FormData();
153
- form.set('file', await fileFromPath(flags.dataset));
154
- const uploadResp = await callApi(config, 'POST', '/v1/uploads', null, form);
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(config, 'POST', '/v1/jobs', {
173
- type: 'train.lora',
174
- input,
175
- policy: { max_cost: flags.maxCost, max_runtime_minutes: maxRuntime, tier: flags.tier },
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 { detail = await callApi(config, 'GET', `/v1/jobs/${job.job_id}`); } catch { continue; }
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', 'echo "$TRAIN_CONFIG_B64" | base64 -d > /tmp/config.yaml && axolotl train /tmp/config.yaml'],
369
+ command: ['sh', '-c', trainCmd],
296
370
  gpu,
297
371
  gpu_count: 1,
298
372
  ...(flags.region ? { region: flags.region.toUpperCase() } : {}),