badgr-cli 1.0.43 → 1.0.45

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, printFailureClass as _printFailureClass } from '../progress.js';
7
8
 
8
9
  const LLAMA_CPP_IMAGE = 'michaelmanleyx/llama-cpp:server-cuda';
9
10
 
@@ -64,18 +65,36 @@ function parseEnvFlag(envList) {
64
65
  return obj;
65
66
  }
66
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
+
67
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.
68
94
  function _inferServeProfile(modelName) {
69
- const s = modelName.toLowerCase();
70
- const moe = s.match(/(\d+)x(\d+)b/);
71
- let paramsB;
72
- if (moe) {
73
- paramsB = parseInt(moe[1]) * parseInt(moe[2]);
74
- } else {
75
- const m = s.match(/(\d+)b/);
76
- paramsB = m ? parseInt(m[1]) : null;
77
- }
95
+ const paramsB = _extractParamsB(modelName);
78
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'] };
79
98
  if (paramsB <= 9) return { label: 'inference (7B–8B model)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] };
80
99
  if (paramsB <= 35) return { label: 'inference (30B–34B model)', vram: '40+ GB', gpus: ['A6000', 'L40S', 'A100'] };
81
100
  return { label: 'inference (70B+ model)', vram: '80+ GB', gpus: ['H100', 'A100'] };
@@ -84,32 +103,16 @@ function _inferServeProfile(modelName) {
84
103
  // Mirror of backend workload_profile.py infer_profile_from_gguf.
85
104
  // Accepts the --hf-file filename; looks for param-count hints like "35B" or "8x7B".
86
105
  function _inferGgufProfile(ggufPath) {
87
- const s = ggufPath.toLowerCase();
88
- const moe = s.match(/(\d+)x(\d+)b/);
89
- let paramsB;
90
- if (moe) {
91
- paramsB = parseInt(moe[1]) * parseInt(moe[2]);
92
- } else {
93
- const m = s.match(/(\d+)b/);
94
- paramsB = m ? parseInt(m[1]) : null;
95
- }
106
+ const paramsB = _extractParamsB(ggufPath);
96
107
  if (paramsB === null || paramsB <= 9) return { label: 'GGUF inference (≤9B, llama.cpp)', vram: '8+ GB', gpus: ['RTX 4090', 'RTX 3090', 'A6000'] };
97
108
  if (paramsB <= 35) return { label: 'GGUF inference (10B–35B, llama.cpp)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] };
98
109
  return { label: 'GGUF inference (36B+, llama.cpp)', vram: '48+ GB', gpus: ['A6000', 'L40S', 'A100'] };
99
110
  }
100
111
 
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;
109
- }
110
- if (elapsedSec < 30) return 'Starting container…';
111
- return `Waiting for ${healthPath || 'endpoint'}…`;
112
- }
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;
113
116
 
114
117
  function _detectHealthPath(image) {
115
118
  if (!image) return null;
@@ -129,10 +132,15 @@ function _detectHealthPath(image) {
129
132
  // vLLM cold start (model download + load) often exceeds 5 min on first boot.
130
133
  const VLLM_SERVE_WAIT_MS = 15 * 60 * 1000;
131
134
 
132
- async function waitForEndpoint(deploymentId, config, timeoutMs = VLLM_SERVE_WAIT_MS, chalk, healthPath = '/models') {
135
+ async function waitForEndpoint(deploymentId, config, timeoutMs = VLLM_SERVE_WAIT_MS, chalk, healthPath = '/models', costCtx = {}) {
136
+ const { costPerHour = 0, maxCost = null, stageLine = '' } = costCtx;
133
137
  const startMs = Date.now();
134
138
  const deadline = startMs + timeoutMs;
135
139
 
140
+ let lastReason = null;
141
+ let reasonSinceMs = startMs;
142
+ let blockLines = 0;
143
+
136
144
  while (Date.now() < deadline) {
137
145
  try {
138
146
  const dep = await callApi(`/deployments/${deploymentId}`, {
@@ -141,24 +149,35 @@ async function waitForEndpoint(deploymentId, config, timeoutMs = VLLM_SERVE_WAIT
141
149
  timeoutMs: 10_000,
142
150
  });
143
151
  if (['failed', 'terminated', 'error', 'stopped'].includes(dep.status)) {
144
- process.stdout.write('\n');
145
- return { ready: false, timedOut: false, depFailed: true, failReason: dep.fix_hint || dep.error || dep.status };
152
+ if (blockLines > 0) { _clearBlock(blockLines); blockLines = 0; }
153
+ return {
154
+ ready: false, timedOut: false, depFailed: true,
155
+ failReason: dep.fix_hint || dep.error || dep.status,
156
+ failureClass: dep.failure_class ?? null,
157
+ nextAction: dep.next_action ?? null,
158
+ };
146
159
  }
147
160
  if (dep.endpoint_ready) {
148
- process.stdout.write('\n');
161
+ if (blockLines > 0) { _clearBlock(blockLines); blockLines = 0; }
149
162
  return { ready: true, timedOut: false, depFailed: false };
150
163
  }
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
- );
164
+
165
+ const now = Date.now();
166
+ const elapsed = Math.round((now - startMs) / 1000);
167
+ const reason = dep.readiness_reason || 'starting';
168
+ if (reason !== lastReason) { lastReason = reason; reasonSinceMs = now; }
169
+ const statusWord = (now - reasonSinceMs) >= SERVE_STUCK_THRESHOLD_MS ? 'stuck' : 'still starting';
170
+ const spend = costPerHour * (elapsed / 3600);
171
+
172
+ blockLines = _writeBlock(blockLines, _renderLiveBlock(chalk, {
173
+ stageLine, elapsedSec: elapsed, statusWord, spend, id: deploymentId,
174
+ }));
155
175
  } catch {
156
176
  // status check failed (transient network/API issue) — retry next tick
157
177
  }
158
178
  await new Promise(r => setTimeout(r, 8000));
159
179
  }
160
180
 
161
- process.stdout.write('\n');
162
181
  return { ready: false, timedOut: true, depFailed: false };
163
182
  }
164
183
 
@@ -194,6 +213,101 @@ const _KNOWN_SERVE_FLAGS = new Set([
194
213
  '--persistent', '--yes', '-y', '--runtime', '--hf-repo', '--hf-file',
195
214
  ]);
196
215
 
216
+ function extractFlag(args, flagName) {
217
+ const rest = [];
218
+ let value = null;
219
+ for (let i = 0; i < args.length; i++) {
220
+ if (args[i] === flagName) { value = args[++i]; continue; }
221
+ rest.push(args[i]);
222
+ }
223
+ return { value, rest };
224
+ }
225
+
226
+ // Returns { id, endpointUrl } for a running vLLM endpoint matching modelId, or
227
+ // null. The id is needed so a caller that auto-launches a new one can print a
228
+ // precise `badgr down <id>` hint if something later fails.
229
+ async function findRunningVllmEndpoint(config, modelId) {
230
+ try {
231
+ const existing = await listDeployments(config);
232
+ const ACTIVE = new Set(['running', 'provisioning', 'starting', 'queued']);
233
+ const match = (existing.deployments ?? []).find(d =>
234
+ ACTIVE.has(d.status) && d.workload_type === 'endpoint' && d.model === modelId
235
+ );
236
+ if (!match) return null;
237
+ const endpointUrl = match.endpoint_url || match.openai_base_url || null;
238
+ return endpointUrl ? { id: match.deployment_id, endpointUrl } : null;
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ /**
245
+ * `badgr serve openwebui [--model <alias-or-id>] [--connect <url>] [flags]`
246
+ *
247
+ * Open WebUI serves a chat UI that connects to a model endpoint — usually
248
+ * vLLM. It is a separate served app from the model itself: this reuses a
249
+ * running vLLM endpoint for the model if one exists, or launches one via
250
+ * `serveCommand` first, then launches Open WebUI wired to it via
251
+ * OPENAI_API_BASE_URL/OPENAI_API_KEY. `--connect` skips vLLM discovery/launch
252
+ * entirely and points Open WebUI at any existing OpenAI-compatible endpoint —
253
+ * no model is ever touched in that mode.
254
+ */
255
+ async function serveOpenWebUICommand(config, args, chalk) {
256
+ requireApiKey(config);
257
+ const { value: connect, rest: afterConnect } = extractFlag(args, '--connect');
258
+ const { value: model, rest } = extractFlag(afterConnect, '--model');
259
+
260
+ let endpointUrl = connect || null;
261
+ let launchedVllmDepId = null; // set only if we launched a new vLLM (for cleanup hints)
262
+
263
+ if (!endpointUrl) {
264
+ const alias = model || 'qwen-7b';
265
+ const vllmAlias = BLESSED_VLLM_MODELS[alias];
266
+ const modelId = vllmAlias ? vllmAlias.model_id : alias;
267
+
268
+ const existing = await findRunningVllmEndpoint(config, modelId);
269
+ if (existing) {
270
+ endpointUrl = existing.endpointUrl;
271
+ console.log(chalk.dim(` Reusing running vLLM endpoint for ${modelId}: ${endpointUrl}\n`));
272
+ } else {
273
+ // Auto-launching vLLM reuses rest's --max-cost/--persistent, which also
274
+ // apply to Open WebUI below — that's two separately billed deployments,
275
+ // not a shared budget, so say so instead of a silent double spend.
276
+ const { value: webuiMaxCost } = extractFlag(rest, '--max-cost');
277
+ console.log(chalk.bold(`\n Open WebUI needs a model endpoint behind it — no running vLLM endpoint for ${modelId} found.\n`));
278
+ if (webuiMaxCost) {
279
+ console.log(chalk.yellow(` Launching vLLM first with its own --max-cost $${webuiMaxCost} cap (separate from Open WebUI's).`));
280
+ console.log(chalk.yellow(` Total possible spend across both deployments: ~$${(parseFloat(webuiMaxCost) * 2).toFixed(2)}.\n`));
281
+ }
282
+ await serveCommand(config, [alias, ...rest], chalk);
283
+ const launched = await findRunningVllmEndpoint(config, modelId);
284
+ if (!launched) {
285
+ console.error(chalk.red(`\n ✗ vLLM endpoint for ${modelId} did not come up — see output above.\n`));
286
+ console.error(chalk.dim(` Once it's ready, run: badgr serve openwebui --connect <endpoint-url>\n`));
287
+ process.exitCode = 1;
288
+ return;
289
+ }
290
+ endpointUrl = launched.endpointUrl;
291
+ launchedVllmDepId = launched.id;
292
+ }
293
+ }
294
+
295
+ const overrides = parseTemplateOverrides(rest);
296
+ // User-supplied --env intentionally wins over the auto-wired connection env.
297
+ overrides.env = {
298
+ OPENAI_API_BASE_URL: endpointUrl,
299
+ OPENAI_API_KEY: config.apiKey || 'sk-local',
300
+ ...overrides.env,
301
+ };
302
+ const flags = buildTemplateFlags(TEMPLATE_MAP.openwebui, overrides);
303
+ await serveCommand(config, flags, chalk);
304
+
305
+ if (process.exitCode && launchedVllmDepId) {
306
+ console.error(chalk.yellow(`\n Open WebUI failed to start. The vLLM endpoint it was connecting to is still running and billing separately.`));
307
+ console.error(chalk.dim(` Stop it with: badgr down ${launchedVllmDepId}\n`));
308
+ }
309
+ }
310
+
197
311
  export async function serveCommand(config, args, chalk) {
198
312
  // `badgr serve template <name> [flags]` — expand template defaults then re-dispatch
199
313
  if (args[0] === 'template') {
@@ -216,14 +330,21 @@ export async function serveCommand(config, args, chalk) {
216
330
  return serveCommand(config, expandedArgs, chalk);
217
331
  }
218
332
 
333
+ // `badgr serve openwebui|open-webui [flags]` — a chat UI that connects to a
334
+ // model endpoint (usually vLLM). See serveOpenWebUICommand.
335
+ if (args[0] && ['openwebui', 'open-webui'].includes(args[0].toLowerCase())) {
336
+ return serveOpenWebUICommand(config, args.slice(1), chalk);
337
+ }
338
+
219
339
  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)}`);
340
+ console.log(chalk.bold('\nTested model routes:\n'));
341
+ for (const alias of Object.keys(BLESSED_VLLM_MODELS)) {
342
+ console.log(` - ${chalk.cyan(alias)}`);
225
343
  }
226
344
  console.log();
345
+ console.log(chalk.dim('You can also try a Hugging Face model ID:'));
346
+ console.log(chalk.dim(' badgr serve Qwen/Qwen2.5-7B-Instruct --max-cost 10'));
347
+ console.log();
227
348
  return;
228
349
  }
229
350
 
@@ -314,50 +435,60 @@ export async function serveCommand(config, args, chalk) {
314
435
 
315
436
  const effectiveTier = normalizeTier(flags.tier);
316
437
 
438
+ // Header fields + trailing note (shown after Max cost) per serve mode.
439
+ // Built as [label, value] pairs so every mode renders through one aligned
440
+ // printer instead of four hand-spaced copies.
441
+ let title;
442
+ const headerLines = [];
443
+ let trailingNote = null;
444
+ let sizeProfile = null; // { label, vram } — only shown when GPU sizing was inferred, not chosen
445
+
317
446
  if (isLlamaCpp) {
318
- console.log(chalk.bold('\n⚡ Serving HF GGUF (llama.cpp)\n'));
319
- console.log(` ${chalk.bold('HF Repo:')} ${flags.hfRepo}`);
320
- console.log(` ${chalk.bold('HF File:')} ${flags.hfFile}`);
321
- console.log(` ${chalk.bold('Runtime:')} llama.cpp`);
322
- console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
323
- if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
324
- if (gpu === 'AUTO') {
325
- const prof = _inferGgufProfile(flags.hfFile);
326
- console.log();
327
- console.log(` ${chalk.bold('Estimated workload:')} ${prof.label}`);
328
- console.log(` ${chalk.bold('Estimated minimum VRAM:')} ${prof.vram}`);
329
- }
447
+ title = flags.hfRepo;
448
+ headerLines.push(['Route', 'best-effort Hugging Face model']);
449
+ headerLines.push(['Mode', 'OpenAI-compatible endpoint (llama.cpp)']);
450
+ headerLines.push(['File', flags.hfFile]);
451
+ trailingNote = 'Badgr will try a compatible route.';
452
+ if (gpu === 'AUTO') sizeProfile = _inferGgufProfile(flags.hfFile);
330
453
  } else if (customImage) {
331
- console.log(chalk.bold('\n⚡ Serving custom container\n'));
332
- console.log(` ${chalk.bold('Image:')} ${customImage}`);
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(', ')}`);
454
+ title = 'custom container';
455
+ headerLines.push(['Mode', 'custom server']);
456
+ trailingNote = 'Badgr manages runtime, logs, caps, teardown, and receipts.\n Your container owns the app behavior.';
457
+ } else if (vllmAlias) {
458
+ title = model;
459
+ headerLines.push(['Route', 'tested']);
460
+ headerLines.push(['Mode', 'OpenAI-compatible endpoint']);
336
461
  } else {
337
- console.log(chalk.bold('\n⚡ Serving model\n'));
338
- if (vllmAlias) {
339
- console.log(` ${chalk.bold('Alias:')} ${model} ${chalk.dim(`→ ${effectiveModel}`)}`);
340
- } else {
341
- console.log(` ${chalk.bold('Model:')} ${effectiveModel}`);
342
- }
343
- console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
344
- if (flags.task) console.log(` ${chalk.bold('Task:')} ${flags.task}`);
345
- if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
346
-
347
- if (gpu === 'AUTO') {
348
- const prof = _inferServeProfile(effectiveModel);
349
- console.log();
350
- console.log(` ${chalk.bold('Estimated workload:')} ${prof.label}`);
351
- console.log(` ${chalk.bold('Estimated minimum VRAM:')} ${prof.vram}`);
352
- }
462
+ title = effectiveModel;
463
+ headerLines.push(['Route', 'best-effort Hugging Face model']);
464
+ headerLines.push(['Mode', 'OpenAI-compatible endpoint']);
465
+ trailingNote = 'Badgr will try a compatible route.';
466
+ if (gpu === 'AUTO') sizeProfile = _inferServeProfile(effectiveModel);
353
467
  }
354
- if (flags.maxCost) {
355
- console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)} (auto-stop)`);
356
- } else {
357
- console.log(chalk.yellow(` ⚠ Persistent — billing until: badgr down <id> or badgr down --all`));
468
+ if (flags.gpu) headerLines.push(['GPU', gpuLabel]);
469
+ if (flags.task) headerLines.push(['Task', flags.task]);
470
+ if (flags.env?.length) headerLines.push(['Env', flags.env.join(', ')]);
471
+
472
+ console.log(chalk.bold(`\n⚡ Serving ${title}\n`));
473
+ const labelWidth = Math.max(...headerLines.map(([label]) => label.length)) + 1;
474
+ for (const [label, value] of headerLines) {
475
+ console.log(` ${chalk.bold(`${label}:`.padEnd(labelWidth + 1))}${value}`);
476
+ }
477
+ if (sizeProfile) {
478
+ console.log();
479
+ console.log(` ${chalk.bold('Estimated workload:')} ${sizeProfile.label}`);
480
+ console.log(` ${chalk.bold('Estimated minimum VRAM:')} ${sizeProfile.vram}`);
481
+ }
482
+ console.log(` ${chalk.bold('Max cost:')} ${flags.maxCost ? `$${flags.maxCost.toFixed(2)}` : chalk.dim('none')}`);
483
+ console.log(` ${chalk.bold('Auto-stop:')} ${flags.maxCost ? 'enabled' : chalk.yellow('disabled — stop manually with badgr down')}`);
484
+ if (trailingNote) {
485
+ console.log();
486
+ console.log(chalk.dim(` ${trailingNote}`));
358
487
  }
359
488
  console.log();
360
- process.stdout.write(chalk.dim(' Finding suitable capacity...\n'));
489
+
490
+ const STAGE_TOTAL = 5;
491
+ let stageN = 1;
361
492
 
362
493
  // ── Duplicate check ────────────────────────────────────────────────────────
363
494
  if (config.apiKey) {
@@ -469,6 +600,11 @@ export async function serveCommand(config, args, chalk) {
469
600
  createdAt: new Date().toISOString(),
470
601
  });
471
602
 
603
+ console.log(chalk.dim(_stageDone(stageN, STAGE_TOTAL, 'Finding a working route...')));
604
+ stageN++;
605
+ console.log(chalk.dim(_stageDone(stageN, STAGE_TOTAL, 'Starting runtime...')));
606
+ stageN++;
607
+
472
608
  // ── Fix 7: never fall back to config.baseUrl for endpoint health check ─────
473
609
  const endpointUrl = dep.endpoint_url || dep.openai_base_url;
474
610
  if (!endpointUrl) {
@@ -499,18 +635,48 @@ export async function serveCommand(config, args, chalk) {
499
635
  resolvedHealthPath = _detectHealthPath(customImage); // '/system_stats' for comfyui, null otherwise
500
636
  }
501
637
 
638
+ // Gated-model guidance is only shown when it's actually needed — on failure —
639
+ // not up front, so common launches stay short and uncluttered.
640
+ const gatedModelId = isLlamaCpp ? flags.hfRepo : effectiveModel;
641
+ const gatedHintNeeded = isLikelyGatedModel(gatedModelId) && !envObjHasHfToken(flags.env);
642
+
643
+ // Shared reporting for "deployment failed to start" — hit from both the
644
+ // pre-poll status check and the waitForEndpoint poll loop below.
645
+ function reportDeployFailure(failReason, failureInfo = {}) {
646
+ console.error(formatCliError('HEALTH_CHECK_DEPLOY_FAILED', {
647
+ deploymentId: dep.deployment_id,
648
+ failReason,
649
+ }, chalk));
650
+ _printFailureClass(chalk, { failure_class: failureInfo.failureClass, next_action: failureInfo.nextAction });
651
+ if (gatedHintNeeded) {
652
+ const rerun = args.join(' ');
653
+ const retryCmd = rerun.includes('HF_TOKEN=') ? rerun : `${rerun} --env HF_TOKEN=$HF_TOKEN`;
654
+ console.error();
655
+ console.error(chalk.yellow(' This model may require Hugging Face access.'));
656
+ console.error(chalk.dim(' Retry:'));
657
+ console.error(chalk.dim(` badgr serve ${retryCmd}`));
658
+ }
659
+ updateReceipt(rcptId, { status: 'failed', failReason });
660
+ process.exitCode = 1;
661
+ }
662
+
502
663
  // ── Health check ──────────────────────────────────────────────────────────
664
+ const loadingStageN = stageN; // "Loading model..."
665
+ const healthStageN = stageN + 1; // "Checking endpoint health..."
666
+ const readyStageN = stageN + 2; // "Ready"
667
+
503
668
  let endpointReady = false;
504
669
  if (flags.noWait) {
505
- console.log(chalk.yellow('\n Skipped health check (--no-wait)\n'));
670
+ console.log(chalk.dim(_stage(loadingStageN, STAGE_TOTAL, 'Loading model...')) + chalk.yellow(' (skipped --no-wait)'));
671
+ console.log(chalk.dim(_stage(healthStageN, STAGE_TOTAL, 'Checking endpoint health...')) + chalk.yellow(' (skipped — --no-wait)'));
672
+ console.log(chalk.yellow(_stage(readyStageN, STAGE_TOTAL, 'Not confirmed ready — check badgr logs') + `\n`));
506
673
  } else if (resolvedHealthPath === null) {
674
+ console.log(chalk.dim(_stage(loadingStageN, STAGE_TOTAL, 'Loading model...')));
507
675
  console.log(chalk.yellow(
508
- '\n Skipping health check (custom image add --health-path /your-readiness-path to enable)\n'
676
+ _stage(healthStageN, STAGE_TOTAL, 'Checking endpoint health...') +
677
+ ' (skipped — custom image; add --health-path /your-readiness-path to enable)\n'
509
678
  ));
510
679
  } else {
511
- if (resolvedHealthPath !== '/models') {
512
- process.stdout.write(chalk.dim(` Checking ${resolvedHealthPath} for readiness…\n`));
513
- }
514
680
  // Check deployment status once before starting the 5-min wait
515
681
  try {
516
682
  const latest = await callApi(`/deployments/${dep.deployment_id}`, {
@@ -519,33 +685,35 @@ export async function serveCommand(config, args, chalk) {
519
685
  timeoutMs: 10_000,
520
686
  });
521
687
  if (['failed', 'terminated', 'error'].includes(latest.status)) {
522
- console.error(formatCliError('HEALTH_CHECK_DEPLOY_FAILED', {
523
- deploymentId: dep.deployment_id,
524
- failReason: latest.error || latest.status,
525
- }, chalk));
526
- updateReceipt(rcptId, { status: 'failed', failReason: latest.error || latest.status });
527
- process.exitCode = 1;
688
+ reportDeployFailure(latest.error || latest.status, {
689
+ failureClass: latest.failure_class ?? null, nextAction: latest.next_action ?? null,
690
+ });
528
691
  return;
529
692
  }
530
693
  } catch {
531
694
  // status check failed — proceed with endpoint poll anyway
532
695
  }
533
696
 
534
- const healthResult = await waitForEndpoint(dep.deployment_id, config, VLLM_SERVE_WAIT_MS, chalk, resolvedHealthPath);
535
- process.stdout.write('\n');
697
+ const loadingStageLine = _stage(loadingStageN, STAGE_TOTAL, 'Loading model...');
698
+ const healthResult = await waitForEndpoint(
699
+ dep.deployment_id, config, VLLM_SERVE_WAIT_MS, chalk, resolvedHealthPath,
700
+ { costPerHour: dep.cost_per_hour || 0, maxCost: flags.maxCost || null, stageLine: loadingStageLine },
701
+ );
536
702
 
537
703
  if (healthResult.depFailed) {
538
- console.error(formatCliError('HEALTH_CHECK_DEPLOY_FAILED', {
539
- deploymentId: dep.deployment_id,
540
- failReason: healthResult.failReason,
541
- }, chalk));
542
- updateReceipt(rcptId, { status: 'failed', failReason: healthResult.failReason });
543
- process.exitCode = 1;
704
+ reportDeployFailure(healthResult.failReason, {
705
+ failureClass: healthResult.failureClass, nextAction: healthResult.nextAction,
706
+ });
544
707
  return;
545
708
  }
546
709
 
547
710
  endpointReady = healthResult.ready;
548
- if (!endpointReady) updateReceipt(rcptId, { status: 'health_check_timeout' });
711
+ if (endpointReady) {
712
+ console.log(chalk.dim(_stage(healthStageN, STAGE_TOTAL, 'Checking endpoint health...')));
713
+ console.log(chalk.green(_stage(readyStageN, STAGE_TOTAL, 'Ready')));
714
+ } else {
715
+ updateReceipt(rcptId, { status: 'health_check_timeout' });
716
+ }
549
717
  }
550
718
 
551
719
  // ── Custom-node validation (ComfyUI) ─────────────────────────────────────
@@ -570,8 +738,6 @@ export async function serveCommand(config, args, chalk) {
570
738
  console.log();
571
739
  }
572
740
 
573
- const serveRate = dep.cost_per_hour || 0;
574
-
575
741
  console.log(` ${chalk.bold('Base URL:')} ${chalk.cyan(endpointUrl)}`);
576
742
  if (isLlamaCpp) {
577
743
  console.log(` ${chalk.bold('HF Repo:')} ${flags.hfRepo}`);
@@ -579,23 +745,20 @@ export async function serveCommand(config, args, chalk) {
579
745
  }
580
746
  else if (dep.model || effectiveModel) console.log(` ${chalk.bold('Model:')} ${dep.model || effectiveModel}`);
581
747
  if (customImage) console.log(` ${chalk.bold('Image:')} ${customImage}`);
582
- console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
583
- if (serveRate > 0) console.log(` ${chalk.bold('Rate:')} $${serveRate.toFixed(2)}/hr`);
584
748
  if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
585
- console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
586
- console.log(` ${chalk.bold('Logs:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
749
+ console.log(` ${chalk.bold('Logs:')} badgr logs ${dep.deployment_id}`);
587
750
  console.log(` ${chalk.bold('Stop billing:')} ${chalk.cyan(`badgr down ${dep.deployment_id}`)}`);
751
+ console.log(` ${chalk.bold('Receipt:')} badgr receipts ${rcptId}`);
588
752
  console.log();
589
-
590
- if (serveRate > HIGH_RATE_THRESHOLD && !flags.maxCost) {
591
- console.log(chalk.yellow(` Selected capacity rate: $${serveRate.toFixed(2)}/hr`));
592
- console.log(chalk.dim(' Tip: use --max-cost to enforce a hard ceiling.\n'));
593
- }
594
753
  console.log(chalk.dim(' Billing continues until you run: ') + chalk.cyan(`badgr down ${dep.deployment_id}`));
595
754
 
596
755
  if (endpointReady && !customImage) {
597
756
  const keySnip = config.apiKey?.slice(0, 4) || 'sk-...';
598
757
  const sdkModel = isLlamaCpp ? 'default' : (dep.model || effectiveModel);
758
+ console.log(` ${chalk.bold('Test with curl:')}`);
759
+ console.log(chalk.dim(` curl ${endpointUrl}/chat/completions \\`));
760
+ console.log(chalk.dim(` -H "Authorization: Bearer ${keySnip}..." -H "Content-Type: application/json" \\`));
761
+ console.log(chalk.dim(` -d '{"model":"${sdkModel}","messages":[{"role":"user","content":"Hello"}]}'`));
599
762
  console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
600
763
  console.log(chalk.dim(` from openai import OpenAI`));
601
764
  console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${keySnip}...")`));
@@ -10,6 +10,7 @@ import { requireApiKey } from '../config.js';
10
10
  import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
11
11
  import { normalizeTier, callWithFallback } from '../fallback.js';
12
12
  import { monitorBatchJob, fmtRuntime } from '../batch.js';
13
+ import { pollJobUntilTerminal, renderJobClosingBlock } from '../progress.js';
13
14
 
14
15
  const MAX_CONFIG_B = 512 * 1024; // 512 KB config limit
15
16
 
@@ -239,35 +240,29 @@ export async function trainLoraCommand(config, args, chalk) {
239
240
  console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
240
241
  console.log(chalk.dim('\n Polling for completion — Ctrl+C to detach (GPU keeps running)\n'));
241
242
 
242
- // Poll until complete
243
- const startMs = Date.now();
244
243
  const maxMs = maxRuntime * 60 * 1000;
245
- while (Date.now() - startMs < maxMs) {
246
- await new Promise(r => setTimeout(r, 15_000));
247
- let detail;
248
- try {
249
- detail = await callApi(`/jobs/${job.job_id}`, {
250
- apiKey: config.apiKey,
251
- baseUrl: config.baseUrl,
252
- });
253
- } catch { continue; }
254
- process.stdout.write(`\r Status: ${detail.status} elapsed: ${Math.floor((Date.now() - startMs) / 1000)}s `);
255
- if (detail.status === 'completed') {
256
- const out = detail.output || {};
257
- console.log(chalk.green('\n\n ✓ Training complete\n'));
258
- if (out.adapter_url) console.log(` ${chalk.bold('Adapter:')} ${out.adapter_url}`);
259
- if (out.checkpoint_url) console.log(` ${chalk.bold('Checkpoint:')} ${out.checkpoint_url}`);
260
- console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}\n`);
261
- return;
262
- }
263
- if (detail.status === 'failed') {
264
- console.error(chalk.red(`\n\n ✗ Training failed: ${detail.error_code || ''} — ${detail.error_message || ''}\n`));
265
- process.exitCode = 1;
266
- return;
267
- }
244
+ const { outcome, detail } = await pollJobUntilTerminal(callApi, config, job.job_id, { chalk, maxMs });
245
+
246
+ if (outcome === 'polling_failed') {
247
+ console.error(chalk.red(`\n\n ✗ Lost contact with Badgr — could not confirm job status.\n`));
248
+ console.error(chalk.dim(` badgr status\n badgr logs ${job.job_id}\n`));
249
+ process.exitCode = 1;
250
+ return;
251
+ }
252
+ if (outcome === 'timed_out') {
253
+ console.error(chalk.yellow('\n\n Training still running detached. Check status:\n'));
254
+ console.error(chalk.dim(` badgr status\n`));
255
+ return;
256
+ }
257
+
258
+ if (outcome === 'completed') {
259
+ const out = detail.output || {};
260
+ if (out.adapter_url) console.log(`\n ${chalk.bold('Adapter:')} ${out.adapter_url}`);
261
+ if (out.checkpoint_url) console.log(` ${chalk.bold('Checkpoint:')} ${out.checkpoint_url}`);
262
+ if (out.warning) console.log(chalk.yellow(` ${out.warning}`));
268
263
  }
269
- console.error(chalk.yellow('\n\n Training still running — detached. Check status:\n'));
270
- console.error(chalk.dim(` badgr status\n`));
264
+ console.log(renderJobClosingBlock(chalk, detail, rcptId));
265
+ if (outcome === 'failed') process.exitCode = 1;
271
266
  }
272
267
 
273
268
  export async function trainCommand(config, args, chalk) {