badgr-cli 1.0.42 → 1.0.44

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.
@@ -1,9 +1,10 @@
1
1
  import { requireApiKey } from '../config.js';
2
2
  import { callApi, listDeployments } from '../api.js';
3
3
  import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../store.js';
4
- import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
4
+ import { normalizeTier, callWithFallback } from '../fallback.js';
5
5
  import { formatCliError } from '../errors.js';
6
- import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides, BLESSED_VLLM_MODELS } from '../catalog.js';
6
+ import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides, BLESSED_VLLM_MODELS, isLikelyGatedModel } from '../catalog.js';
7
+ import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock } from '../progress.js';
7
8
 
8
9
  const LLAMA_CPP_IMAGE = 'michaelmanleyx/llama-cpp:server-cuda';
9
10
 
@@ -42,6 +43,7 @@ export function parseServeArgs(args) {
42
43
  if (args[i] === '--runtime') { flags.runtime = args[++i]; i++; continue; }
43
44
  if (args[i] === '--hf-repo') { flags.hfRepo = args[++i]; i++; continue; }
44
45
  if (args[i] === '--hf-file') { flags.hfFile = args[++i]; i++; continue; }
46
+ if (args[i] === '--list-aliases') { flags.listAliases = true; i++; continue; }
45
47
  if (args[i] === '--env') {
46
48
  const kv = args[++i]; i++;
47
49
  if (!flags.env) flags.env = [];
@@ -63,18 +65,36 @@ function parseEnvFlag(envList) {
63
65
  return obj;
64
66
  }
65
67
 
68
+ function envObjHasHfToken(envList) {
69
+ return (envList || []).some(kv => kv.startsWith('HF_TOKEN='));
70
+ }
71
+
72
+ // Extract a parameter-count-in-billions hint from a model/file name, e.g.
73
+ // "Qwen2.5-0.5B-Instruct" → 0.5, "Llama-3.1-8B-Instruct" → 8, "Mixtral-8x7B" → 56.
74
+ // Splits into delimiter-bounded segments first so a version number like "2.5"
75
+ // in "Qwen2.5-0.5B" is never mistaken for the param count — only a segment that
76
+ // IS entirely "<digits>b" or "<digits>x<digits>b" counts as a size hint.
77
+ function _extractParamsB(name) {
78
+ const s = name.toLowerCase();
79
+ const segments = s.split(/[^a-z0-9.]+/).filter(Boolean);
80
+ for (const seg of segments) {
81
+ const moe = seg.match(/^(\d+)x(\d+)b$/);
82
+ if (moe) return parseInt(moe[1], 10) * parseInt(moe[2], 10);
83
+ }
84
+ for (const seg of segments) {
85
+ const m = seg.match(/^(\d+(?:\.\d+)?)b$/);
86
+ if (m) return parseFloat(m[1]);
87
+ }
88
+ return null;
89
+ }
90
+
66
91
  // Mirror of backend workload_profile.py infer_profile_from_model — for pre-flight display.
92
+ // Sizing is only asserted when a param-count hint is found in the name; unknown
93
+ // sizing falls back to the 7B–8B/24GB+ default rather than guessing small or large.
67
94
  function _inferServeProfile(modelName) {
68
- const s = modelName.toLowerCase();
69
- const moe = s.match(/(\d+)x(\d+)b/);
70
- let paramsB;
71
- if (moe) {
72
- paramsB = parseInt(moe[1]) * parseInt(moe[2]);
73
- } else {
74
- const m = s.match(/(\d+)b/);
75
- paramsB = m ? parseInt(m[1]) : null;
76
- }
95
+ const paramsB = _extractParamsB(modelName);
77
96
  if (paramsB === null) return { label: 'inference (7B–8B model)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] };
97
+ if (paramsB <= 3) return { label: 'inference (≤3B model)', vram: '8+ GB', gpus: ['RTX 3090', 'RTX 4090', 'L4'] };
78
98
  if (paramsB <= 9) return { label: 'inference (7B–8B model)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] };
79
99
  if (paramsB <= 35) return { label: 'inference (30B–34B model)', vram: '40+ GB', gpus: ['A6000', 'L40S', 'A100'] };
80
100
  return { label: 'inference (70B+ model)', vram: '80+ GB', gpus: ['H100', 'A100'] };
@@ -83,35 +103,16 @@ function _inferServeProfile(modelName) {
83
103
  // Mirror of backend workload_profile.py infer_profile_from_gguf.
84
104
  // Accepts the --hf-file filename; looks for param-count hints like "35B" or "8x7B".
85
105
  function _inferGgufProfile(ggufPath) {
86
- const s = ggufPath.toLowerCase();
87
- const moe = s.match(/(\d+)x(\d+)b/);
88
- let paramsB;
89
- if (moe) {
90
- paramsB = parseInt(moe[1]) * parseInt(moe[2]);
91
- } else {
92
- const m = s.match(/(\d+)b/);
93
- paramsB = m ? parseInt(m[1]) : null;
94
- }
106
+ const paramsB = _extractParamsB(ggufPath);
95
107
  if (paramsB === null || paramsB <= 9) return { label: 'GGUF inference (≤9B, llama.cpp)', vram: '8+ GB', gpus: ['RTX 4090', 'RTX 3090', 'A6000'] };
96
108
  if (paramsB <= 35) return { label: 'GGUF inference (10B–35B, llama.cpp)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] };
97
109
  return { label: 'GGUF inference (36B+, llama.cpp)', vram: '48+ GB', gpus: ['A6000', 'L40S', 'A100'] };
98
110
  }
99
111
 
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…';
105
- }
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}…`;
114
- }
112
+ // If the readiness reason hasn't changed for this long, the status word
113
+ // flips from "starting" to "stuck" so a silent stall never looks identical
114
+ // to normal progress.
115
+ const SERVE_STUCK_THRESHOLD_MS = 90_000;
115
116
 
116
117
  function _detectHealthPath(image) {
117
118
  if (!image) return null;
@@ -121,16 +122,26 @@ function _detectHealthPath(image) {
121
122
  }
122
123
 
123
124
  /**
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.
125
+ * Wait for the deployment's app-level endpoint to become ready by polling Badgr's
126
+ * own deployment status (GET /deployments/{id})never the RunPod proxy/pod
127
+ * directly. The backend does the real health_path probing (see
128
+ * DeploymentService.check_endpoint_readiness); a pod reporting RUNNING only
129
+ * means infrastructure is up, not that the app inside is serving.
126
130
  * Returns { ready: boolean, timedOut: boolean, depFailed: boolean, failReason?: string }
127
131
  */
128
- async function waitForEndpoint(endpointUrl, deploymentId, config, timeoutMs = 5 * 60 * 1000, chalk, healthPath = '/models') {
132
+ // vLLM cold start (model download + load) often exceeds 5 min on first boot.
133
+ const VLLM_SERVE_WAIT_MS = 15 * 60 * 1000;
134
+
135
+ async function waitForEndpoint(deploymentId, config, timeoutMs = VLLM_SERVE_WAIT_MS, chalk, healthPath = '/models', costCtx = {}) {
136
+ const { costPerHour = 0, maxCost = null, stageLine = '' } = costCtx;
129
137
  const startMs = Date.now();
130
138
  const deadline = startMs + timeoutMs;
131
139
 
140
+ let lastReason = null;
141
+ let reasonSinceMs = startMs;
142
+ let blockLines = 0;
143
+
132
144
  while (Date.now() < deadline) {
133
- // Check deployment status first — fail fast on OOM/crash before waiting more
134
145
  try {
135
146
  const dep = await callApi(`/deployments/${deploymentId}`, {
136
147
  apiKey: config.apiKey,
@@ -138,28 +149,30 @@ async function waitForEndpoint(endpointUrl, deploymentId, config, timeoutMs = 5
138
149
  timeoutMs: 10_000,
139
150
  });
140
151
  if (['failed', 'terminated', 'error', 'stopped'].includes(dep.status)) {
141
- process.stdout.write('\n');
142
- return { ready: false, timedOut: false, depFailed: true, failReason: dep.error || dep.status };
152
+ if (blockLines > 0) { _clearBlock(blockLines); blockLines = 0; }
153
+ return { ready: false, timedOut: false, depFailed: true, failReason: dep.fix_hint || dep.error || dep.status };
154
+ }
155
+ if (dep.endpoint_ready) {
156
+ if (blockLines > 0) { _clearBlock(blockLines); blockLines = 0; }
157
+ return { ready: true, timedOut: false, depFailed: false };
143
158
  }
144
- } catch {
145
- // status check failed — continue to endpoint check
146
- }
147
159
 
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 }; }
160
+ const now = Date.now();
161
+ const elapsed = Math.round((now - startMs) / 1000);
162
+ const reason = dep.readiness_reason || 'starting';
163
+ if (reason !== lastReason) { lastReason = reason; reasonSinceMs = now; }
164
+ const statusWord = (now - reasonSinceMs) >= SERVE_STUCK_THRESHOLD_MS ? 'stuck' : 'still starting';
165
+ const spend = costPerHour * (elapsed / 3600);
166
+
167
+ blockLines = _writeBlock(blockLines, _renderLiveBlock(chalk, {
168
+ stageLine, elapsedSec: elapsed, statusWord, spend, id: deploymentId,
169
+ }));
151
170
  } catch {
152
- // still starting
171
+ // status check failed (transient network/API issue) — retry next tick
153
172
  }
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
173
  await new Promise(r => setTimeout(r, 8000));
160
174
  }
161
175
 
162
- process.stdout.write('\n');
163
176
  return { ready: false, timedOut: true, depFailed: false };
164
177
  }
165
178
 
@@ -217,6 +230,18 @@ export async function serveCommand(config, args, chalk) {
217
230
  return serveCommand(config, expandedArgs, chalk);
218
231
  }
219
232
 
233
+ if (args.includes('--list-aliases')) {
234
+ console.log(chalk.bold('\nTested model routes:\n'));
235
+ for (const alias of Object.keys(BLESSED_VLLM_MODELS)) {
236
+ console.log(` - ${chalk.cyan(alias)}`);
237
+ }
238
+ console.log();
239
+ console.log(chalk.dim('You can also try a Hugging Face model ID:'));
240
+ console.log(chalk.dim(' badgr serve Qwen/Qwen2.5-7B-Instruct --max-cost 10'));
241
+ console.log();
242
+ return;
243
+ }
244
+
220
245
  const { model, flags } = parseServeArgs(args);
221
246
  const customImage = flags.image || null;
222
247
  const isLlamaCpp = flags.runtime === 'llama.cpp';
@@ -304,50 +329,60 @@ export async function serveCommand(config, args, chalk) {
304
329
 
305
330
  const effectiveTier = normalizeTier(flags.tier);
306
331
 
332
+ // Header fields + trailing note (shown after Max cost) per serve mode.
333
+ // Built as [label, value] pairs so every mode renders through one aligned
334
+ // printer instead of four hand-spaced copies.
335
+ let title;
336
+ const headerLines = [];
337
+ let trailingNote = null;
338
+ let sizeProfile = null; // { label, vram } — only shown when GPU sizing was inferred, not chosen
339
+
307
340
  if (isLlamaCpp) {
308
- console.log(chalk.bold('\n⚡ Serving HF GGUF (llama.cpp)\n'));
309
- console.log(` ${chalk.bold('HF Repo:')} ${flags.hfRepo}`);
310
- console.log(` ${chalk.bold('HF File:')} ${flags.hfFile}`);
311
- console.log(` ${chalk.bold('Runtime:')} llama.cpp`);
312
- console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
313
- if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
314
- if (gpu === 'AUTO') {
315
- const prof = _inferGgufProfile(flags.hfFile);
316
- console.log();
317
- console.log(` ${chalk.bold('Estimated workload:')} ${prof.label}`);
318
- console.log(` ${chalk.bold('Estimated minimum VRAM:')} ${prof.vram}`);
319
- }
341
+ title = flags.hfRepo;
342
+ headerLines.push(['Route', 'best-effort Hugging Face model']);
343
+ headerLines.push(['Mode', 'OpenAI-compatible endpoint (llama.cpp)']);
344
+ headerLines.push(['File', flags.hfFile]);
345
+ trailingNote = 'Badgr will try a compatible route.';
346
+ if (gpu === 'AUTO') sizeProfile = _inferGgufProfile(flags.hfFile);
320
347
  } else if (customImage) {
321
- console.log(chalk.bold('\n⚡ Serving custom container\n'));
322
- console.log(` ${chalk.bold('Image:')} ${customImage}`);
323
- console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
324
- if (flags.task) console.log(` ${chalk.bold('Task:')} ${flags.task}`);
325
- if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
348
+ title = 'custom container';
349
+ headerLines.push(['Mode', 'custom server']);
350
+ trailingNote = 'Badgr manages runtime, logs, caps, teardown, and receipts.\n Your container owns the app behavior.';
351
+ } else if (vllmAlias) {
352
+ title = model;
353
+ headerLines.push(['Route', 'tested']);
354
+ headerLines.push(['Mode', 'OpenAI-compatible endpoint']);
326
355
  } else {
327
- console.log(chalk.bold('\n⚡ Serving model\n'));
328
- if (vllmAlias) {
329
- console.log(` ${chalk.bold('Alias:')} ${model} ${chalk.dim(`→ ${effectiveModel}`)}`);
330
- } else {
331
- console.log(` ${chalk.bold('Model:')} ${effectiveModel}`);
332
- }
333
- console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
334
- if (flags.task) console.log(` ${chalk.bold('Task:')} ${flags.task}`);
335
- if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
336
-
337
- if (gpu === 'AUTO') {
338
- const prof = _inferServeProfile(effectiveModel);
339
- console.log();
340
- console.log(` ${chalk.bold('Estimated workload:')} ${prof.label}`);
341
- console.log(` ${chalk.bold('Estimated minimum VRAM:')} ${prof.vram}`);
342
- }
356
+ title = effectiveModel;
357
+ headerLines.push(['Route', 'best-effort Hugging Face model']);
358
+ headerLines.push(['Mode', 'OpenAI-compatible endpoint']);
359
+ trailingNote = 'Badgr will try a compatible route.';
360
+ if (gpu === 'AUTO') sizeProfile = _inferServeProfile(effectiveModel);
343
361
  }
344
- if (flags.maxCost) {
345
- console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)} (auto-stop)`);
346
- } else {
347
- console.log(chalk.yellow(` ⚠ Persistent — billing until: badgr down <id> or badgr down --all`));
362
+ if (flags.gpu) headerLines.push(['GPU', gpuLabel]);
363
+ if (flags.task) headerLines.push(['Task', flags.task]);
364
+ if (flags.env?.length) headerLines.push(['Env', flags.env.join(', ')]);
365
+
366
+ console.log(chalk.bold(`\n⚡ Serving ${title}\n`));
367
+ const labelWidth = Math.max(...headerLines.map(([label]) => label.length)) + 1;
368
+ for (const [label, value] of headerLines) {
369
+ console.log(` ${chalk.bold(`${label}:`.padEnd(labelWidth + 1))}${value}`);
370
+ }
371
+ if (sizeProfile) {
372
+ console.log();
373
+ console.log(` ${chalk.bold('Estimated workload:')} ${sizeProfile.label}`);
374
+ console.log(` ${chalk.bold('Estimated minimum VRAM:')} ${sizeProfile.vram}`);
375
+ }
376
+ console.log(` ${chalk.bold('Max cost:')} ${flags.maxCost ? `$${flags.maxCost.toFixed(2)}` : chalk.dim('none')}`);
377
+ console.log(` ${chalk.bold('Auto-stop:')} ${flags.maxCost ? 'enabled' : chalk.yellow('disabled — stop manually with badgr down')}`);
378
+ if (trailingNote) {
379
+ console.log();
380
+ console.log(chalk.dim(` ${trailingNote}`));
348
381
  }
349
382
  console.log();
350
- process.stdout.write(chalk.dim(' Finding suitable capacity...\n'));
383
+
384
+ const STAGE_TOTAL = 5;
385
+ let stageN = 1;
351
386
 
352
387
  // ── Duplicate check ────────────────────────────────────────────────────────
353
388
  if (config.apiKey) {
@@ -402,6 +437,7 @@ export async function serveCommand(config, args, chalk) {
402
437
  tier: tierOverride || effectiveTier,
403
438
  ...(Object.keys(effectiveEnv).length > 0 ? { env: effectiveEnv } : {}),
404
439
  ...(flags.maxCost ? { max_cost_usd: flags.maxCost } : {}),
440
+ ...(flags.healthPath ? { health_path: flags.healthPath } : {}),
405
441
  };
406
442
  }
407
443
 
@@ -458,6 +494,11 @@ export async function serveCommand(config, args, chalk) {
458
494
  createdAt: new Date().toISOString(),
459
495
  });
460
496
 
497
+ console.log(chalk.dim(_stageDone(stageN, STAGE_TOTAL, 'Finding a working route...')));
498
+ stageN++;
499
+ console.log(chalk.dim(_stageDone(stageN, STAGE_TOTAL, 'Starting runtime...')));
500
+ stageN++;
501
+
461
502
  // ── Fix 7: never fall back to config.baseUrl for endpoint health check ─────
462
503
  const endpointUrl = dep.endpoint_url || dep.openai_base_url;
463
504
  if (!endpointUrl) {
@@ -488,18 +529,47 @@ export async function serveCommand(config, args, chalk) {
488
529
  resolvedHealthPath = _detectHealthPath(customImage); // '/system_stats' for comfyui, null otherwise
489
530
  }
490
531
 
532
+ // Gated-model guidance is only shown when it's actually needed — on failure —
533
+ // not up front, so common launches stay short and uncluttered.
534
+ const gatedModelId = isLlamaCpp ? flags.hfRepo : effectiveModel;
535
+ const gatedHintNeeded = isLikelyGatedModel(gatedModelId) && !envObjHasHfToken(flags.env);
536
+
537
+ // Shared reporting for "deployment failed to start" — hit from both the
538
+ // pre-poll status check and the waitForEndpoint poll loop below.
539
+ function reportDeployFailure(failReason) {
540
+ console.error(formatCliError('HEALTH_CHECK_DEPLOY_FAILED', {
541
+ deploymentId: dep.deployment_id,
542
+ failReason,
543
+ }, chalk));
544
+ if (gatedHintNeeded) {
545
+ const rerun = args.join(' ');
546
+ const retryCmd = rerun.includes('HF_TOKEN=') ? rerun : `${rerun} --env HF_TOKEN=$HF_TOKEN`;
547
+ console.error();
548
+ console.error(chalk.yellow(' This model may require Hugging Face access.'));
549
+ console.error(chalk.dim(' Retry:'));
550
+ console.error(chalk.dim(` badgr serve ${retryCmd}`));
551
+ }
552
+ updateReceipt(rcptId, { status: 'failed', failReason });
553
+ process.exitCode = 1;
554
+ }
555
+
491
556
  // ── Health check ──────────────────────────────────────────────────────────
557
+ const loadingStageN = stageN; // "Loading model..."
558
+ const healthStageN = stageN + 1; // "Checking endpoint health..."
559
+ const readyStageN = stageN + 2; // "Ready"
560
+
492
561
  let endpointReady = false;
493
562
  if (flags.noWait) {
494
- console.log(chalk.yellow('\n Skipped health check (--no-wait)\n'));
563
+ console.log(chalk.dim(_stage(loadingStageN, STAGE_TOTAL, 'Loading model...')) + chalk.yellow(' (skipped --no-wait)'));
564
+ console.log(chalk.dim(_stage(healthStageN, STAGE_TOTAL, 'Checking endpoint health...')) + chalk.yellow(' (skipped — --no-wait)'));
565
+ console.log(chalk.yellow(_stage(readyStageN, STAGE_TOTAL, 'Not confirmed ready — check badgr logs') + `\n`));
495
566
  } else if (resolvedHealthPath === null) {
567
+ console.log(chalk.dim(_stage(loadingStageN, STAGE_TOTAL, 'Loading model...')));
496
568
  console.log(chalk.yellow(
497
- '\n Skipping health check (custom image add --health-path /your-readiness-path to enable)\n'
569
+ _stage(healthStageN, STAGE_TOTAL, 'Checking endpoint health...') +
570
+ ' (skipped — custom image; add --health-path /your-readiness-path to enable)\n'
498
571
  ));
499
572
  } else {
500
- if (resolvedHealthPath !== '/models') {
501
- process.stdout.write(chalk.dim(` Checking ${resolvedHealthPath} for readiness…\n`));
502
- }
503
573
  // Check deployment status once before starting the 5-min wait
504
574
  try {
505
575
  const latest = await callApi(`/deployments/${dep.deployment_id}`, {
@@ -508,33 +578,31 @@ export async function serveCommand(config, args, chalk) {
508
578
  timeoutMs: 10_000,
509
579
  });
510
580
  if (['failed', 'terminated', 'error'].includes(latest.status)) {
511
- console.error(formatCliError('HEALTH_CHECK_DEPLOY_FAILED', {
512
- deploymentId: dep.deployment_id,
513
- failReason: latest.error || latest.status,
514
- }, chalk));
515
- updateReceipt(rcptId, { status: 'failed', failReason: latest.error || latest.status });
516
- process.exitCode = 1;
581
+ reportDeployFailure(latest.error || latest.status);
517
582
  return;
518
583
  }
519
584
  } catch {
520
585
  // status check failed — proceed with endpoint poll anyway
521
586
  }
522
587
 
523
- const healthResult = await waitForEndpoint(endpointUrl, dep.deployment_id, config, 5 * 60 * 1000, chalk, resolvedHealthPath);
524
- process.stdout.write('\n');
588
+ const loadingStageLine = _stage(loadingStageN, STAGE_TOTAL, 'Loading model...');
589
+ const healthResult = await waitForEndpoint(
590
+ dep.deployment_id, config, VLLM_SERVE_WAIT_MS, chalk, resolvedHealthPath,
591
+ { costPerHour: dep.cost_per_hour || 0, maxCost: flags.maxCost || null, stageLine: loadingStageLine },
592
+ );
525
593
 
526
594
  if (healthResult.depFailed) {
527
- console.error(formatCliError('HEALTH_CHECK_DEPLOY_FAILED', {
528
- deploymentId: dep.deployment_id,
529
- failReason: healthResult.failReason,
530
- }, chalk));
531
- updateReceipt(rcptId, { status: 'failed', failReason: healthResult.failReason });
532
- process.exitCode = 1;
595
+ reportDeployFailure(healthResult.failReason);
533
596
  return;
534
597
  }
535
598
 
536
599
  endpointReady = healthResult.ready;
537
- if (!endpointReady) updateReceipt(rcptId, { status: 'health_check_timeout' });
600
+ if (endpointReady) {
601
+ console.log(chalk.dim(_stage(healthStageN, STAGE_TOTAL, 'Checking endpoint health...')));
602
+ console.log(chalk.green(_stage(readyStageN, STAGE_TOTAL, 'Ready')));
603
+ } else {
604
+ updateReceipt(rcptId, { status: 'health_check_timeout' });
605
+ }
538
606
  }
539
607
 
540
608
  // ── Custom-node validation (ComfyUI) ─────────────────────────────────────
@@ -559,8 +627,6 @@ export async function serveCommand(config, args, chalk) {
559
627
  console.log();
560
628
  }
561
629
 
562
- const serveRate = dep.cost_per_hour || 0;
563
-
564
630
  console.log(` ${chalk.bold('Base URL:')} ${chalk.cyan(endpointUrl)}`);
565
631
  if (isLlamaCpp) {
566
632
  console.log(` ${chalk.bold('HF Repo:')} ${flags.hfRepo}`);
@@ -568,23 +634,20 @@ export async function serveCommand(config, args, chalk) {
568
634
  }
569
635
  else if (dep.model || effectiveModel) console.log(` ${chalk.bold('Model:')} ${dep.model || effectiveModel}`);
570
636
  if (customImage) console.log(` ${chalk.bold('Image:')} ${customImage}`);
571
- console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
572
- if (serveRate > 0) console.log(` ${chalk.bold('Rate:')} $${serveRate.toFixed(2)}/hr`);
573
637
  if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
574
- console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
575
- console.log(` ${chalk.bold('Logs:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
638
+ console.log(` ${chalk.bold('Logs:')} badgr logs ${dep.deployment_id}`);
576
639
  console.log(` ${chalk.bold('Stop billing:')} ${chalk.cyan(`badgr down ${dep.deployment_id}`)}`);
640
+ console.log(` ${chalk.bold('Receipt:')} badgr receipts ${rcptId}`);
577
641
  console.log();
578
-
579
- if (serveRate > HIGH_RATE_THRESHOLD && !flags.maxCost) {
580
- console.log(chalk.yellow(` Selected capacity rate: $${serveRate.toFixed(2)}/hr`));
581
- console.log(chalk.dim(' Tip: use --max-cost to enforce a hard ceiling.\n'));
582
- }
583
642
  console.log(chalk.dim(' Billing continues until you run: ') + chalk.cyan(`badgr down ${dep.deployment_id}`));
584
643
 
585
644
  if (endpointReady && !customImage) {
586
645
  const keySnip = config.apiKey?.slice(0, 4) || 'sk-...';
587
646
  const sdkModel = isLlamaCpp ? 'default' : (dep.model || effectiveModel);
647
+ console.log(` ${chalk.bold('Test with curl:')}`);
648
+ console.log(chalk.dim(` curl ${endpointUrl}/chat/completions \\`));
649
+ console.log(chalk.dim(` -H "Authorization: Bearer ${keySnip}..." -H "Content-Type: application/json" \\`));
650
+ console.log(chalk.dim(` -d '{"model":"${sdkModel}","messages":[{"role":"user","content":"Hello"}]}'`));
588
651
  console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
589
652
  console.log(chalk.dim(` from openai import OpenAI`));
590
653
  console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${keySnip}...")`));
@@ -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() } : {}),