badgr-cli 1.0.44 → 1.0.46

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.
@@ -8,7 +8,8 @@ import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
8
8
  import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
9
9
  import { formatCliError } from '../errors.js';
10
10
  import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides } from '../catalog.js';
11
- import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock } from '../progress.js';
11
+ import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass } from '../progress.js';
12
+ import { detectWorkload, workloadTypeLabel } from '../detect.js';
12
13
 
13
14
  /**
14
15
  * Flow 1 — local project (primary):
@@ -56,6 +57,10 @@ export function parseRunArgs(args) {
56
57
  if (flagArgs[i] === '--save') { flags.save = flagArgs[++i]; i++; continue; }
57
58
  if (flagArgs[i] === '--workspace') { flags.workspace = flagArgs[++i]; i++; continue; }
58
59
  if (flagArgs[i] === '--cmd') { flags.cmd = flagArgs[++i]; i++; continue; }
60
+ if (flagArgs[i] === '--output') { flags.output = flagArgs[++i]; i++; continue; }
61
+ if (flagArgs[i] === '--checkpoint') { flags.checkpoint = flagArgs[++i]; i++; continue; }
62
+ if (flagArgs[i] === '--retry-safe') { flags.retrySafe = true; i++; continue; }
63
+ if (flagArgs[i] === '--resume-cmd') { flags.resumeCmd = flagArgs[++i]; i++; continue; }
59
64
  if (flagArgs[i] === '--env') {
60
65
  const kv = flagArgs[++i]; i++;
61
66
  if (!flags.env) flags.env = [];
@@ -339,6 +344,8 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
339
344
  exitCode,
340
345
  runtimeMs: Date.now() - startMs,
341
346
  failureType: classifyFailure(status, exitCode),
347
+ failureClass: dep.failure_class ?? null,
348
+ nextAction: dep.next_action ?? null,
342
349
  };
343
350
  }
344
351
  }
@@ -357,6 +364,7 @@ const _KNOWN_RUN_FLAGS = new Set([
357
364
  '--detach', '--fallback', '--no-fallback', '--strict-capacity',
358
365
  '--no-expanded-search', '--max-runtime', '--max-cost', '--min-vram', '--env',
359
366
  '--dry-run', '--cmd', '--save', '--workspace',
367
+ '--output', '--checkpoint', '--retry-safe', '--resume-cmd',
360
368
  ]);
361
369
 
362
370
  // Directories and files always excluded from project zip uploads.
@@ -497,10 +505,43 @@ export async function runCommand(config, args, chalk) {
497
505
  const isGitHubUrl = firstArg && _isGitHubUrl(firstArg);
498
506
  const isCodeSource = isLocalPath || isGitHubUrl;
499
507
 
508
+ // Local paths can be inspected on disk, so a missing --cmd doesn't have to be
509
+ // an error — try to infer it (and output/checkpoint conventions) the same way
510
+ // `badgr detect .` would, before ever provisioning anything.
511
+ let detectionReport = null;
512
+ if (isLocalPath && !flags.cmd) {
513
+ detectionReport = detectWorkload(firstArg);
514
+ if (detectionReport.command && detectionReport.confidence !== 'low') {
515
+ flags.cmd = detectionReport.command;
516
+ console.log(chalk.dim(`\n Detected command (${detectionReport.confidence} confidence): ${detectionReport.command}`));
517
+ console.log(chalk.dim(` Workload: ${workloadTypeLabel(detectionReport.workloadType)} • badgr detect . for the full report • --cmd to override`));
518
+ }
519
+ if (!flags.output && detectionReport.outputs?.length) flags.output = detectionReport.outputs[0];
520
+ if (!flags.checkpoint && detectionReport.checkpoints?.length) flags.checkpoint = detectionReport.checkpoints[0];
521
+ }
522
+
523
+ // Detection couldn't confidently name a command — ask for just that one
524
+ // missing essential (interactively, if we have a terminal to ask on).
525
+ if (isLocalPath && !flags.cmd && process.stdin.isTTY && process.stdout.isTTY) {
526
+ console.log(chalk.dim(`\n Badgr couldn't confidently detect a command to run in ${path.resolve(firstArg)}.`));
527
+ try {
528
+ const { input } = await import('@inquirer/prompts');
529
+ const answer = await input({
530
+ message: 'Which command should run?',
531
+ default: detectionReport?.command || undefined,
532
+ validate: v => v.trim() ? true : 'A command is required',
533
+ });
534
+ flags.cmd = answer.trim();
535
+ } catch {
536
+ // Ctrl+C or a non-interactive stdin that lied about isTTY — fall through to the hard error below.
537
+ }
538
+ }
539
+
500
540
  if (isCodeSource && !flags.cmd) {
501
541
  console.error(chalk.red(`\n ✗ --cmd is required when running from a ${isLocalPath ? 'local path' : 'GitHub URL'}.\n`));
502
542
  if (isLocalPath) {
503
543
  console.error(chalk.dim(' Example: badgr run . --cmd "python train.py" --max-cost 5\n'));
544
+ console.error(chalk.dim(' Or check what Badgr detects first: badgr detect .\n'));
504
545
  } else {
505
546
  console.error(chalk.dim(' Example: badgr run https://github.com/user/repo --cmd "python train.py" --max-cost 5\n'));
506
547
  }
@@ -550,6 +591,20 @@ export async function runCommand(config, args, chalk) {
550
591
  return;
551
592
  }
552
593
 
594
+ if (!flags.maxCost && !flags.dryRun && isLocalPath && process.stdin.isTTY && process.stdout.isTTY) {
595
+ try {
596
+ const { input } = await import('@inquirer/prompts');
597
+ const answer = await input({
598
+ message: 'What max cost ($) should cap this run?',
599
+ default: '5',
600
+ validate: v => (Number.isFinite(parseFloat(v)) && parseFloat(v) > 0) ? true : 'Enter a number greater than 0',
601
+ });
602
+ flags.maxCost = parseFloat(answer);
603
+ } catch {
604
+ // fall through to the hard error below
605
+ }
606
+ }
607
+
553
608
  if (!flags.maxCost && !flags.dryRun) {
554
609
  console.error(chalk.red('\n ✗ --max-cost is required for run workloads.\n'));
555
610
  console.error(chalk.dim(' Example: badgr run --gpu RTX_4090 --image node:20 --max-cost 5 -- node script.js\n'));
@@ -578,7 +633,16 @@ export async function runCommand(config, args, chalk) {
578
633
  const maxRuntimeMs = effectiveMaxRuntime * 60 * 1000;
579
634
 
580
635
  const maxCost = flags.maxCost ?? null;
581
- const envObj = parseEnvFlag(flags.env);
636
+ // BADGR_OUTPUT_DIR / BADGR_CHECKPOINT_DIR / BADGR_RETRY_SAFE are a
637
+ // convention for custom.run containers to write recoverable state outside
638
+ // the pod — Badgr doesn't enforce what the container does with them, it
639
+ // just wires the directories through and surfaces --resume-cmd on failure.
640
+ // Explicit --env always wins if the user passes the same key directly.
641
+ const conventionEnv = {};
642
+ if (flags.output) conventionEnv.BADGR_OUTPUT_DIR = flags.output;
643
+ if (flags.checkpoint) conventionEnv.BADGR_CHECKPOINT_DIR = flags.checkpoint;
644
+ if (flags.retrySafe) conventionEnv.BADGR_RETRY_SAFE = '1';
645
+ const envObj = { ...conventionEnv, ...parseEnvFlag(flags.env) };
582
646
  const effectiveTier = normalizeTier(flags.tier);
583
647
 
584
648
  const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : undefined;
@@ -595,6 +659,10 @@ export async function runCommand(config, args, chalk) {
595
659
  if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost}`);
596
660
  if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice}/hr`);
597
661
  if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
662
+ if (flags.output) console.log(` ${chalk.bold('Output:')} ${flags.output}`);
663
+ if (flags.checkpoint) console.log(` ${chalk.bold('Checkpoint:')} ${flags.checkpoint}`);
664
+ if (flags.retrySafe) console.log(` ${chalk.bold('Retry-safe:')} enabled`);
665
+ if (flags.resumeCmd) console.log(` ${chalk.bold('Resume cmd:')} ${flags.resumeCmd}`);
598
666
  console.log(chalk.dim('\n Remove --dry-run to provision.\n'));
599
667
  return;
600
668
  }
@@ -616,6 +684,9 @@ export async function runCommand(config, args, chalk) {
616
684
  if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice.toFixed(2)}/hr`);
617
685
  if (detach) console.log(` ${chalk.dim('(detached — returns immediately)')}`);
618
686
  if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
687
+ if (flags.output) console.log(` ${chalk.bold('Output:')} ${flags.output}`);
688
+ if (flags.checkpoint) console.log(` ${chalk.bold('Checkpoint:')} ${flags.checkpoint}`);
689
+ if (flags.retrySafe) console.log(` ${chalk.bold('Retry-safe:')} enabled`);
619
690
  console.log();
620
691
 
621
692
 
@@ -775,6 +846,7 @@ export async function runCommand(config, args, chalk) {
775
846
  console.log(chalk.dim(_stage(STAGE_TOTAL, STAGE_TOTAL, stageLabels[reason] ?? 'Stopped')));
776
847
  console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Estimated cost: ~$${finalCost.toFixed(2)}`));
777
848
  _printFinalInfo(chalk, { exitCode: null, teardownOk, jobId: dep.deployment_id, rcptId });
849
+ if (flags.resumeCmd) console.log(` ${chalk.bold('Resume:')} ${flags.resumeCmd}`);
778
850
  console.log();
779
851
  }
780
852
 
@@ -805,6 +877,7 @@ export async function runCommand(config, args, chalk) {
805
877
  if (dep.status === 'failed') {
806
878
  process.removeListener('SIGINT', handleShutdown);
807
879
  console.error(formatCliError('JOB_INFRASTRUCTURE_FAILURE', { receiptId: rcptId }, chalk));
880
+ _printFailureClass(chalk, dep);
808
881
  process.exitCode = 1;
809
882
  return;
810
883
  }
@@ -813,7 +886,7 @@ export async function runCommand(config, args, chalk) {
813
886
  console.log(chalk.dim(`\n [${stageN}/${STAGE_TOTAL}] Running command (Ctrl+C to stop)`));
814
887
 
815
888
  attachStart = Date.now();
816
- const { status: finalStatus, exitCode, runtimeMs, failureType } = await attachToJob(config, dep.deployment_id, {
889
+ const { status: finalStatus, exitCode, runtimeMs, failureType, failureClass, nextAction } = await attachToJob(config, dep.deployment_id, {
817
890
  chalk,
818
891
  maxRuntimeMs,
819
892
  maxCost,
@@ -853,11 +926,13 @@ export async function runCommand(config, args, chalk) {
853
926
  } else {
854
927
  console.error(formatCliError('JOB_FAILED', { exitCode, deploymentId: dep.deployment_id }, chalk));
855
928
  }
929
+ _printFailureClass(chalk, { failure_class: failureClass, next_action: nextAction });
856
930
  console.log(chalk.dim(_stage(STAGE_TOTAL, STAGE_TOTAL, 'Failed')));
857
931
  console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Estimated cost: ~$${finalCost.toFixed(2)}`));
858
932
  // The container already reached a terminal state on the provider side by the
859
933
  // time we observe it here, so billing is already stopped — no extra teardown call needed.
860
934
  _printFinalInfo(chalk, { exitCode, teardownOk: true, jobId: dep.deployment_id, rcptId });
935
+ if (flags.resumeCmd) console.log(` ${chalk.bold('Resume:')} ${flags.resumeCmd}`);
861
936
  process.exitCode = exitCode ?? 1;
862
937
  return;
863
938
  }
@@ -4,7 +4,7 @@ import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../
4
4
  import { normalizeTier, callWithFallback } from '../fallback.js';
5
5
  import { formatCliError } from '../errors.js';
6
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
+ import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass } from '../progress.js';
8
8
 
9
9
  const LLAMA_CPP_IMAGE = 'michaelmanleyx/llama-cpp:server-cuda';
10
10
 
@@ -32,6 +32,7 @@ export function parseServeArgs(args) {
32
32
  if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
33
33
  if (args[i] === '--no-wait') { flags.noWait = true; i++; continue; }
34
34
  if (args[i] === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
35
+ if (args[i] === '--idle-timeout') { flags.idleTimeout = parseInt(args[++i], 10); i++; continue; }
35
36
  if (args[i] === '--health-path') { flags.healthPath = args[++i]; i++; continue; }
36
37
  if (args[i] === '--check-nodes') { flags.checkNodes = args[++i]; i++; continue; }
37
38
  // All three aliases map to noMarketplaceFallback
@@ -150,7 +151,12 @@ async function waitForEndpoint(deploymentId, config, timeoutMs = VLLM_SERVE_WAIT
150
151
  });
151
152
  if (['failed', 'terminated', 'error', 'stopped'].includes(dep.status)) {
152
153
  if (blockLines > 0) { _clearBlock(blockLines); blockLines = 0; }
153
- return { ready: false, timedOut: false, depFailed: true, failReason: dep.fix_hint || dep.error || dep.status };
154
+ return {
155
+ ready: false, timedOut: false, depFailed: true,
156
+ failReason: dep.fix_hint || dep.error || dep.status,
157
+ failureClass: dep.failure_class ?? null,
158
+ nextAction: dep.next_action ?? null,
159
+ };
154
160
  }
155
161
  if (dep.endpoint_ready) {
156
162
  if (blockLines > 0) { _clearBlock(blockLines); blockLines = 0; }
@@ -203,11 +209,106 @@ async function validateComfyNodes(baseUrl, nodeList, chalk) {
203
209
  // Known badgr serve flags — used to detect broken shell line continuation.
204
210
  const _KNOWN_SERVE_FLAGS = new Set([
205
211
  '--gpu', '--image', '--task', '--count', '--region', '--tier', '--max-price',
206
- '--name', '--no-wait', '--max-cost', '--health-path', '--check-nodes',
212
+ '--name', '--no-wait', '--max-cost', '--idle-timeout', '--health-path', '--check-nodes',
207
213
  '--no-fallback', '--strict-capacity', '--no-expanded-search', '--env',
208
214
  '--persistent', '--yes', '-y', '--runtime', '--hf-repo', '--hf-file',
209
215
  ]);
210
216
 
217
+ function extractFlag(args, flagName) {
218
+ const rest = [];
219
+ let value = null;
220
+ for (let i = 0; i < args.length; i++) {
221
+ if (args[i] === flagName) { value = args[++i]; continue; }
222
+ rest.push(args[i]);
223
+ }
224
+ return { value, rest };
225
+ }
226
+
227
+ // Returns { id, endpointUrl } for a running vLLM endpoint matching modelId, or
228
+ // null. The id is needed so a caller that auto-launches a new one can print a
229
+ // precise `badgr down <id>` hint if something later fails.
230
+ async function findRunningVllmEndpoint(config, modelId) {
231
+ try {
232
+ const existing = await listDeployments(config);
233
+ const ACTIVE = new Set(['running', 'provisioning', 'starting', 'queued']);
234
+ const match = (existing.deployments ?? []).find(d =>
235
+ ACTIVE.has(d.status) && d.workload_type === 'endpoint' && d.model === modelId
236
+ );
237
+ if (!match) return null;
238
+ const endpointUrl = match.endpoint_url || match.openai_base_url || null;
239
+ return endpointUrl ? { id: match.deployment_id, endpointUrl } : null;
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * `badgr serve openwebui [--model <alias-or-id>] [--connect <url>] [flags]`
247
+ *
248
+ * Open WebUI serves a chat UI that connects to a model endpoint — usually
249
+ * vLLM. It is a separate served app from the model itself: this reuses a
250
+ * running vLLM endpoint for the model if one exists, or launches one via
251
+ * `serveCommand` first, then launches Open WebUI wired to it via
252
+ * OPENAI_API_BASE_URL/OPENAI_API_KEY. `--connect` skips vLLM discovery/launch
253
+ * entirely and points Open WebUI at any existing OpenAI-compatible endpoint —
254
+ * no model is ever touched in that mode.
255
+ */
256
+ async function serveOpenWebUICommand(config, args, chalk) {
257
+ requireApiKey(config);
258
+ const { value: connect, rest: afterConnect } = extractFlag(args, '--connect');
259
+ const { value: model, rest } = extractFlag(afterConnect, '--model');
260
+
261
+ let endpointUrl = connect || null;
262
+ let launchedVllmDepId = null; // set only if we launched a new vLLM (for cleanup hints)
263
+
264
+ if (!endpointUrl) {
265
+ const alias = model || 'qwen-7b';
266
+ const vllmAlias = BLESSED_VLLM_MODELS[alias];
267
+ const modelId = vllmAlias ? vllmAlias.model_id : alias;
268
+
269
+ const existing = await findRunningVllmEndpoint(config, modelId);
270
+ if (existing) {
271
+ endpointUrl = existing.endpointUrl;
272
+ console.log(chalk.dim(` Reusing running vLLM endpoint for ${modelId}: ${endpointUrl}\n`));
273
+ } else {
274
+ // Auto-launching vLLM reuses rest's --max-cost/--persistent, which also
275
+ // apply to Open WebUI below — that's two separately billed deployments,
276
+ // not a shared budget, so say so instead of a silent double spend.
277
+ const { value: webuiMaxCost } = extractFlag(rest, '--max-cost');
278
+ console.log(chalk.bold(`\n Open WebUI needs a model endpoint behind it — no running vLLM endpoint for ${modelId} found.\n`));
279
+ if (webuiMaxCost) {
280
+ console.log(chalk.yellow(` Launching vLLM first with its own --max-cost $${webuiMaxCost} cap (separate from Open WebUI's).`));
281
+ console.log(chalk.yellow(` Total possible spend across both deployments: ~$${(parseFloat(webuiMaxCost) * 2).toFixed(2)}.\n`));
282
+ }
283
+ await serveCommand(config, [alias, ...rest], chalk);
284
+ const launched = await findRunningVllmEndpoint(config, modelId);
285
+ if (!launched) {
286
+ console.error(chalk.red(`\n ✗ vLLM endpoint for ${modelId} did not come up — see output above.\n`));
287
+ console.error(chalk.dim(` Once it's ready, run: badgr serve openwebui --connect <endpoint-url>\n`));
288
+ process.exitCode = 1;
289
+ return;
290
+ }
291
+ endpointUrl = launched.endpointUrl;
292
+ launchedVllmDepId = launched.id;
293
+ }
294
+ }
295
+
296
+ const overrides = parseTemplateOverrides(rest);
297
+ // User-supplied --env intentionally wins over the auto-wired connection env.
298
+ overrides.env = {
299
+ OPENAI_API_BASE_URL: endpointUrl,
300
+ OPENAI_API_KEY: config.apiKey || 'sk-local',
301
+ ...overrides.env,
302
+ };
303
+ const flags = buildTemplateFlags(TEMPLATE_MAP.openwebui, overrides);
304
+ await serveCommand(config, flags, chalk);
305
+
306
+ if (process.exitCode && launchedVllmDepId) {
307
+ console.error(chalk.yellow(`\n Open WebUI failed to start. The vLLM endpoint it was connecting to is still running and billing separately.`));
308
+ console.error(chalk.dim(` Stop it with: badgr down ${launchedVllmDepId}\n`));
309
+ }
310
+ }
311
+
211
312
  export async function serveCommand(config, args, chalk) {
212
313
  // `badgr serve template <name> [flags]` — expand template defaults then re-dispatch
213
314
  if (args[0] === 'template') {
@@ -230,6 +331,12 @@ export async function serveCommand(config, args, chalk) {
230
331
  return serveCommand(config, expandedArgs, chalk);
231
332
  }
232
333
 
334
+ // `badgr serve openwebui|open-webui [flags]` — a chat UI that connects to a
335
+ // model endpoint (usually vLLM). See serveOpenWebUICommand.
336
+ if (args[0] && ['openwebui', 'open-webui'].includes(args[0].toLowerCase())) {
337
+ return serveOpenWebUICommand(config, args.slice(1), chalk);
338
+ }
339
+
233
340
  if (args.includes('--list-aliases')) {
234
341
  console.log(chalk.bold('\nTested model routes:\n'));
235
342
  for (const alias of Object.keys(BLESSED_VLLM_MODELS)) {
@@ -437,6 +544,7 @@ export async function serveCommand(config, args, chalk) {
437
544
  tier: tierOverride || effectiveTier,
438
545
  ...(Object.keys(effectiveEnv).length > 0 ? { env: effectiveEnv } : {}),
439
546
  ...(flags.maxCost ? { max_cost_usd: flags.maxCost } : {}),
547
+ ...(flags.idleTimeout ? { idle_timeout_minutes: flags.idleTimeout } : {}),
440
548
  ...(flags.healthPath ? { health_path: flags.healthPath } : {}),
441
549
  };
442
550
  }
@@ -536,11 +644,12 @@ export async function serveCommand(config, args, chalk) {
536
644
 
537
645
  // Shared reporting for "deployment failed to start" — hit from both the
538
646
  // pre-poll status check and the waitForEndpoint poll loop below.
539
- function reportDeployFailure(failReason) {
647
+ function reportDeployFailure(failReason, failureInfo = {}) {
540
648
  console.error(formatCliError('HEALTH_CHECK_DEPLOY_FAILED', {
541
649
  deploymentId: dep.deployment_id,
542
650
  failReason,
543
651
  }, chalk));
652
+ _printFailureClass(chalk, { failure_class: failureInfo.failureClass, next_action: failureInfo.nextAction });
544
653
  if (gatedHintNeeded) {
545
654
  const rerun = args.join(' ');
546
655
  const retryCmd = rerun.includes('HF_TOKEN=') ? rerun : `${rerun} --env HF_TOKEN=$HF_TOKEN`;
@@ -578,7 +687,9 @@ export async function serveCommand(config, args, chalk) {
578
687
  timeoutMs: 10_000,
579
688
  });
580
689
  if (['failed', 'terminated', 'error'].includes(latest.status)) {
581
- reportDeployFailure(latest.error || latest.status);
690
+ reportDeployFailure(latest.error || latest.status, {
691
+ failureClass: latest.failure_class ?? null, nextAction: latest.next_action ?? null,
692
+ });
582
693
  return;
583
694
  }
584
695
  } catch {
@@ -592,7 +703,9 @@ export async function serveCommand(config, args, chalk) {
592
703
  );
593
704
 
594
705
  if (healthResult.depFailed) {
595
- reportDeployFailure(healthResult.failReason);
706
+ reportDeployFailure(healthResult.failReason, {
707
+ failureClass: healthResult.failureClass, nextAction: healthResult.nextAction,
708
+ });
596
709
  return;
597
710
  }
598
711
 
@@ -635,22 +748,34 @@ export async function serveCommand(config, args, chalk) {
635
748
  else if (dep.model || effectiveModel) console.log(` ${chalk.bold('Model:')} ${dep.model || effectiveModel}`);
636
749
  if (customImage) console.log(` ${chalk.bold('Image:')} ${customImage}`);
637
750
  if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
751
+ if (flags.idleTimeout) console.log(` ${chalk.bold('Idle timeout:')} ${flags.idleTimeout}m (auto-stops if idle — see Heartbeat below)`);
638
752
  console.log(` ${chalk.bold('Logs:')} badgr logs ${dep.deployment_id}`);
753
+ console.log(` ${chalk.bold('Restart:')} badgr restart ${dep.deployment_id}`);
639
754
  console.log(` ${chalk.bold('Stop billing:')} ${chalk.cyan(`badgr down ${dep.deployment_id}`)}`);
640
755
  console.log(` ${chalk.bold('Receipt:')} badgr receipts ${rcptId}`);
641
756
  console.log();
642
757
  console.log(chalk.dim(' Billing continues until you run: ') + chalk.cyan(`badgr down ${dep.deployment_id}`));
643
758
 
644
759
  if (endpointReady && !customImage) {
645
- const keySnip = config.apiKey?.slice(0, 4) || 'sk-...';
760
+ // dep.endpoint_api_key is a per-endpoint key generated for this deployment
761
+ // (vLLM model serves only) — shown exactly once, here. Falls back to the
762
+ // account-wide key (truncated) for serves that don't get one yet
763
+ // (custom images, managed transcribe/image tasks).
764
+ const hasEndpointKey = Boolean(dep.endpoint_api_key);
765
+ const authKey = hasEndpointKey ? dep.endpoint_api_key : `${config.apiKey?.slice(0, 4) || 'sk-...'}...`;
646
766
  const sdkModel = isLlamaCpp ? 'default' : (dep.model || effectiveModel);
767
+
768
+ if (hasEndpointKey) {
769
+ console.log(` ${chalk.bold('API key:')} ${chalk.yellow(dep.endpoint_api_key)}`);
770
+ console.log(chalk.dim(' Shown once — copy it now. This key is scoped to this endpoint only.'));
771
+ }
647
772
  console.log(` ${chalk.bold('Test with curl:')}`);
648
773
  console.log(chalk.dim(` curl ${endpointUrl}/chat/completions \\`));
649
- console.log(chalk.dim(` -H "Authorization: Bearer ${keySnip}..." -H "Content-Type: application/json" \\`));
774
+ console.log(chalk.dim(` -H "Authorization: Bearer ${authKey}" -H "Content-Type: application/json" \\`));
650
775
  console.log(chalk.dim(` -d '{"model":"${sdkModel}","messages":[{"role":"user","content":"Hello"}]}'`));
651
776
  console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
652
777
  console.log(chalk.dim(` from openai import OpenAI`));
653
- console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${keySnip}...")`));
778
+ console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${authKey}")`));
654
779
  if (flags.task === 'transcribe') {
655
780
  console.log(chalk.dim(` with open("audio.mp3", "rb") as f:`));
656
781
  console.log(chalk.dim(` t = client.audio.transcriptions.create(model="${sdkModel}", file=f, response_format="text")`));
@@ -663,5 +788,13 @@ export async function serveCommand(config, args, chalk) {
663
788
  console.log(chalk.dim(` resp = client.chat.completions.create(model="${sdkModel}", messages=[{"role": "user", "content": "Hello"}])`));
664
789
  }
665
790
  console.log();
791
+ if (flags.idleTimeout) {
792
+ console.log(` ${chalk.bold('Heartbeat (required for --idle-timeout):')}`);
793
+ console.log(chalk.dim(` badgr heartbeat ${dep.deployment_id}`));
794
+ console.log(chalk.dim(' Badgr does not proxy your inference traffic, so call this on each real'));
795
+ console.log(chalk.dim(` request (or wire it into your client) — otherwise this endpoint auto-stops`));
796
+ console.log(chalk.dim(` after ${flags.idleTimeout}m even if it's still reachable.`));
797
+ console.log();
798
+ }
666
799
  }
667
800
  }
@@ -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) {