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.
package/src/badgr.js CHANGED
@@ -20,17 +20,24 @@ import { embedCommand } from './commands/embed.js';
20
20
  import { templateCommand } from './commands/template.js';
21
21
  import { workloadCommand } from './commands/workload.js';
22
22
  import { workspaceCommand } from './commands/workspace.js';
23
+ import { detectCommand } from './commands/detect.js';
24
+ import { restartCommand } from './commands/restart.js';
25
+ import { heartbeatCommand } from './commands/heartbeat.js';
23
26
 
24
27
  const HELP = `
25
28
  ${chalk.bold('badgr')} — run or serve GPU workloads from one command
26
29
 
27
30
  ${chalk.bold('COMMANDS')}
28
31
  ${chalk.cyan('badgr login')} Authenticate with your API key
32
+ ${chalk.cyan('badgr detect <path>')} Inspect a project and report the GPU job Badgr would run
29
33
  ${chalk.cyan('badgr run <command>')} Run a one-off GPU job
30
34
  ${chalk.cyan('badgr serve <model>')} Serve a model with an OpenAI-compatible endpoint
35
+ ${chalk.cyan('badgr serve openwebui')} Serve Open WebUI — chat UI, connects to a model endpoint
31
36
  ${chalk.cyan('badgr status')} Show what's running and what's billing
32
37
  ${chalk.cyan('badgr logs <id>')} Stream logs for a running job or endpoint
33
38
  ${chalk.cyan('badgr down <id>')} Stop a deployment and end billing
39
+ ${chalk.cyan('badgr restart <id>')} Relaunch an endpoint with the same config and API key
40
+ ${chalk.cyan('badgr heartbeat <id>')} Reset an endpoint's idle-timeout clock
34
41
  ${chalk.cyan('badgr receipts')} Show cost history
35
42
  ${chalk.cyan('badgr test')} Run an end-to-end test (provision → run → teardown)
36
43
  ${chalk.cyan('badgr capacity')} Check what GPU capacity is available right now
@@ -53,12 +60,20 @@ ${chalk.bold('SHORTCUTS')} ${chalk.dim('(wrappers around run / serve for common
53
60
  ${chalk.cyan('badgr serve --list-aliases')} List blessed vLLM model shortcuts (qwen-7b, llama-8b, …)
54
61
 
55
62
  ${chalk.bold('EXAMPLES')}
63
+ ${chalk.dim('# Point Badgr at any project and see what it detects:')}
64
+ badgr detect .
65
+ badgr run . --max-cost 5 --save my-job
66
+ badgr workload run my-job
67
+
56
68
  ${chalk.dim('# Verify the stack works end-to-end:')}
57
69
  badgr test
58
70
 
59
71
  ${chalk.dim('# Serve a model (OpenAI-compatible):')}
60
72
  badgr serve meta-llama/Llama-3.1-8B-Instruct --max-cost 10
61
73
 
74
+ ${chalk.dim('# Serve Open WebUI, connected to a model endpoint:')}
75
+ badgr serve openwebui --model qwen-7b --max-cost 10
76
+
62
77
  ${chalk.dim('# Serve a Hugging Face GGUF file via llama.cpp:')}
63
78
  badgr serve --runtime llama.cpp \\
64
79
  --hf-repo org/model-repo \\
@@ -141,11 +156,14 @@ async function main() {
141
156
 
142
157
  switch (cmd) {
143
158
  case 'login': return loginCommand(chalk, saveConfig);
159
+ case 'detect': return detectCommand(config, rest, chalk);
144
160
  case 'run': return runCommand(config, rest, chalk);
145
161
  case 'serve': return serveCommand(config, rest, chalk);
146
162
  case 'status': return statusCommand(config, rest, chalk);
147
163
  case 'logs': return logsCommand(config, rest, chalk);
148
164
  case 'down': return downCommand(config, rest, chalk);
165
+ case 'restart': return restartCommand(config, rest, chalk);
166
+ case 'heartbeat': return heartbeatCommand(config, rest, chalk);
149
167
  case 'receipts': return receiptsCommand(config, rest, chalk);
150
168
  case 'models': return modelsCommand(config, chalk);
151
169
  case 'capacity': return capacityCommand(config, rest, chalk);
package/src/catalog.js CHANGED
@@ -112,6 +112,32 @@ export const TEMPLATES = [
112
112
  'OpenAI client: OpenAI(base_url="<endpoint>/v1", api_key="<key>")',
113
113
  ],
114
114
  },
115
+ {
116
+ name: 'openwebui',
117
+ title: 'Open WebUI',
118
+ description: 'Self-hosted ChatGPT-style web UI for any OpenAI-compatible endpoint',
119
+ type: 'endpoint',
120
+ image: process.env.OPENWEBUI_IMAGE || 'ghcr.io/open-webui/open-webui:main',
121
+ gpu: 'RTX_3080',
122
+ gpu_count: 1,
123
+ port: 8080,
124
+ health_path: '/health',
125
+ min_vram_gb: 8,
126
+ env: {
127
+ OPENAI_API_BASE_URL: '<set-automatically-by-badgr-serve-openwebui>',
128
+ OPENAI_API_KEY: '<your-badgr-api-key>',
129
+ WEBUI_AUTH: 'False',
130
+ },
131
+ notes: [
132
+ 'Preferred: `badgr serve openwebui --model qwen-7b` auto-launches (or reuses) a vLLM',
133
+ 'endpoint and connects Open WebUI to it — no manual env wiring needed.',
134
+ 'Open WebUI is a separate served app from the model — it needs a model endpoint',
135
+ 'behind it (usually vLLM), it is not itself powered by vLLM.',
136
+ 'Or set OPENAI_API_BASE_URL / OPENAI_API_KEY manually via --env to point at any',
137
+ 'OpenAI-compatible server, including one you already have running.',
138
+ 'Chat history and settings persist only for the life of this deployment.',
139
+ ],
140
+ },
115
141
  {
116
142
  name: 'llama-cpp',
117
143
  title: 'llama.cpp Server',
@@ -493,6 +519,11 @@ export const BLESSED_COMFY_WORKFLOWS = {
493
519
  gpu_type: 'RTX_4090',
494
520
  output_type: 'images',
495
521
  },
522
+ 'flux-basic': {
523
+ description: 'FLUX.1-schnell text-to-image',
524
+ gpu_type: 'RTX_4090',
525
+ output_type: 'images',
526
+ },
496
527
  };
497
528
 
498
529
  /**
@@ -9,9 +9,10 @@ import { readFileSync, existsSync } from 'fs';
9
9
  import { requireApiKey } from '../config.js';
10
10
  import { callApi, listDeployments } from '../api.js';
11
11
  import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../store.js';
12
- import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
12
+ import { normalizeTier, callWithFallback } from '../fallback.js';
13
13
  import { formatCliError } from '../errors.js';
14
14
  import { BLESSED_COMFY_WORKFLOWS } from '../catalog.js';
15
+ import { pollJobUntilTerminal, renderJobClosingBlock, stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass } from '../progress.js';
15
16
 
16
17
  const COMFYUI_IMAGE = process.env.COMFYUI_IMAGE || 'yanwk/comfyui-boot:cu126-megapak';
17
18
  const HEALTH_PATH = '/system_stats';
@@ -54,16 +55,14 @@ function parseEnvFlag(envList) {
54
55
  return obj;
55
56
  }
56
57
 
57
- function stageLabel(elapsedSec) {
58
- if (elapsedSec < 30) return 'Starting ComfyUI…';
59
- if (elapsedSec < 120) return 'Downloading models…';
60
- if (elapsedSec < 300) return 'Loading custom nodes…';
61
- return `Waiting for ${HEALTH_PATH}…`;
62
- }
63
-
64
- async function waitForComfyUI(endpointUrl, depId, config, chalk) {
58
+ // Live block while ComfyUI comes up — same generic "Checking health" stage
59
+ // line and status block as every other launch path (see serve.js's
60
+ // waitForEndpoint), so ComfyUI doesn't read as a different UX than vLLM.
61
+ async function waitForComfyUI(endpointUrl, depId, config, chalk, costCtx = {}) {
62
+ const { costPerHour = 0, stageLine = '' } = costCtx;
65
63
  const startMs = Date.now();
66
64
  const deadline = startMs + WAIT_TIMEOUT_MS;
65
+ let blockLines = 0;
67
66
 
68
67
  while (Date.now() < deadline) {
69
68
  try {
@@ -71,22 +70,32 @@ async function waitForComfyUI(endpointUrl, depId, config, chalk) {
71
70
  apiKey: config.apiKey, baseUrl: config.baseUrl, timeoutMs: 10_000,
72
71
  });
73
72
  if (['failed', 'terminated', 'error', 'stopped'].includes(dep.status)) {
74
- process.stdout.write('\n');
75
- return { ready: false, timedOut: false, depFailed: true, failReason: dep.error || dep.status };
73
+ if (blockLines > 0) { _clearBlock(blockLines); blockLines = 0; }
74
+ return {
75
+ ready: false, timedOut: false, depFailed: true,
76
+ failReason: dep.error || dep.status,
77
+ failureClass: dep.failure_class ?? null,
78
+ nextAction: dep.next_action ?? null,
79
+ };
76
80
  }
77
81
  } catch {}
78
82
 
79
83
  try {
80
84
  const res = await fetch(`${endpointUrl}${HEALTH_PATH}`, { signal: AbortSignal.timeout(8000) });
81
- if (res.ok) { process.stdout.write('\n'); return { ready: true, timedOut: false, depFailed: false }; }
85
+ if (res.ok) {
86
+ if (blockLines > 0) { _clearBlock(blockLines); blockLines = 0; }
87
+ return { ready: true, timedOut: false, depFailed: false };
88
+ }
82
89
  } catch {}
83
90
 
84
- const elapsed = Math.round((Date.now() - startMs) / 1000);
85
- process.stdout.write(`\r ${chalk.dim(stageLabel(elapsed) + ` (${elapsed}s)`)} `);
91
+ const elapsedSec = Math.round((Date.now() - startMs) / 1000);
92
+ const spend = costPerHour * (elapsedSec / 3600);
93
+ blockLines = _writeBlock(blockLines, _renderLiveBlock(chalk, {
94
+ stageLine, elapsedSec, statusWord: 'starting', spend, id: depId,
95
+ }));
86
96
  await new Promise(r => setTimeout(r, 8000));
87
97
  }
88
98
 
89
- process.stdout.write('\n');
90
99
  return { ready: false, timedOut: true, depFailed: false };
91
100
  }
92
101
 
@@ -142,7 +151,7 @@ export async function comfyBatchCommand(config, args, chalk) {
142
151
  if (!flags.workflow) {
143
152
  console.error(chalk.red('\n Usage: badgr comfyui batch --workflow sdxl-basic --prompts prompts.txt --max-cost 10\n'));
144
153
  console.error(chalk.dim(' Runs a batch of prompts through a blessed ComfyUI workflow and returns image URLs.\n'));
145
- console.error(chalk.dim(' Blessed workflows: sdxl-basic\n'));
154
+ console.error(chalk.dim(` Blessed workflows: ${Object.keys(BLESSED_COMFY_WORKFLOWS).join(', ')}\n`));
146
155
  process.exitCode = 1;
147
156
  return;
148
157
  }
@@ -232,36 +241,30 @@ export async function comfyBatchCommand(config, args, chalk) {
232
241
  console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
233
242
  console.log(chalk.dim('\n Polling for completion…\n'));
234
243
 
235
- const startMs = Date.now();
236
244
  const maxMs = maxRuntime * 60 * 1000;
237
- while (Date.now() - startMs < maxMs) {
238
- await new Promise(r => setTimeout(r, 15_000));
239
- let detail;
240
- try {
241
- detail = await callApi(`/jobs/${job.job_id}`, {
242
- apiKey: config.apiKey,
243
- baseUrl: config.baseUrl,
244
- });
245
- } catch { continue; }
246
- process.stdout.write(`\r Status: ${detail.status} elapsed: ${Math.floor((Date.now() - startMs) / 1000)}s `);
247
- if (detail.status === 'completed') {
248
- const out = detail.output || {};
249
- console.log(chalk.green('\n\n ✓ Batch complete\n'));
250
- if (out.image_urls && out.image_urls.length > 0) {
251
- console.log(` ${chalk.bold('Images (${out.image_urls.length}):')}`);
252
- out.image_urls.forEach((url, i) => console.log(` ${i + 1}. ${url}`));
253
- }
254
- console.log(`\n ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}\n`);
255
- return;
256
- }
257
- if (detail.status === 'failed') {
258
- console.error(chalk.red(`\n\n ✗ Batch failed: ${detail.error_code || ''} — ${detail.error_message || ''}\n`));
259
- process.exitCode = 1;
260
- return;
245
+ const { outcome, detail } = await pollJobUntilTerminal(callApi, config, job.job_id, { chalk, maxMs });
246
+
247
+ if (outcome === 'polling_failed') {
248
+ console.error(chalk.red(`\n\n ✗ Lost contact with Badgr — could not confirm job status.\n`));
249
+ console.error(chalk.dim(` badgr status\n badgr logs ${job.job_id}\n`));
250
+ process.exitCode = 1;
251
+ return;
252
+ }
253
+ if (outcome === 'timed_out') {
254
+ console.error(chalk.yellow('\n\n Batch still running detached. Check status:\n'));
255
+ console.error(chalk.dim(` badgr status\n`));
256
+ return;
257
+ }
258
+
259
+ if (outcome === 'completed') {
260
+ const out = detail.output || {};
261
+ if (out.image_urls && out.image_urls.length > 0) {
262
+ console.log(`\n ${chalk.bold(`Images (${out.image_urls.length}):`)}`);
263
+ out.image_urls.forEach((url, i) => console.log(` ${i + 1}. ${url}`));
261
264
  }
262
265
  }
263
- console.error(chalk.yellow('\n\n Batch still running — detached. Check status:\n'));
264
- console.error(chalk.dim(` badgr status\n`));
266
+ console.log(renderJobClosingBlock(chalk, detail, rcptId));
267
+ if (outcome === 'failed') process.exitCode = 1;
265
268
  }
266
269
 
267
270
  export async function comfyuiCommand(config, args, chalk) {
@@ -322,7 +325,10 @@ export async function comfyuiCommand(config, args, chalk) {
322
325
  const effectiveTier = normalizeTier(flags.tier);
323
326
  const envObj = { ...parseEnvFlag(flags.env), COMFYUI_WORKFLOW_B64: workflowB64 };
324
327
 
325
- console.log(chalk.bold('\n🎨 ComfyUI\n'));
328
+ const STAGE_TOTAL = 4;
329
+ let stageN = 1;
330
+
331
+ console.log(chalk.bold('\n⚡ Running ComfyUI\n'));
326
332
  console.log(` ${chalk.bold('Workflow:')} ${workflow} (${nodeCount} nodes)`);
327
333
  console.log(` ${chalk.bold('Image:')} ${COMFYUI_IMAGE}`);
328
334
  console.log(` ${chalk.bold('GPU:')} ${gpu === 'AUTO' ? chalk.dim('auto (16+ GB VRAM)') : gpu}`);
@@ -332,7 +338,6 @@ export async function comfyuiCommand(config, args, chalk) {
332
338
  console.log(chalk.yellow(' ⚠ Persistent — billing until: badgr down <id>'));
333
339
  }
334
340
  console.log();
335
- process.stdout.write(chalk.dim(' Finding suitable capacity...\n'));
336
341
 
337
342
  // Duplicate check
338
343
  if (config.apiKey) {
@@ -412,6 +417,11 @@ export async function comfyuiCommand(config, args, chalk) {
412
417
  createdAt: new Date().toISOString(),
413
418
  });
414
419
 
420
+ console.log(chalk.dim(_stageDone(stageN, STAGE_TOTAL, 'Finding a working route...')));
421
+ stageN++;
422
+ console.log(chalk.dim(_stageDone(stageN, STAGE_TOTAL, 'Starting runtime...')));
423
+ stageN++;
424
+
415
425
  const endpointUrl = dep.endpoint_url || dep.openai_base_url;
416
426
  if (!endpointUrl) {
417
427
  console.error(chalk.red(`\n ✗ No endpoint URL returned. Run: badgr status ${dep.deployment_id}\n`));
@@ -420,25 +430,36 @@ export async function comfyuiCommand(config, args, chalk) {
420
430
  return;
421
431
  }
422
432
 
433
+ const healthStageN = stageN;
434
+ const readyStageN = stageN + 1;
435
+
423
436
  let endpointReady = false;
424
437
  if (flags.noWait) {
425
- console.log(chalk.yellow('\n Skipped health check (--no-wait)\n'));
438
+ console.log(chalk.dim(_stage(healthStageN, STAGE_TOTAL, 'Checking health...')) + chalk.yellow(' (skipped — --no-wait)'));
426
439
  } else {
427
- const result = await waitForComfyUI(endpointUrl, dep.deployment_id, config, chalk);
428
- process.stdout.write('\n');
440
+ const stageLine = _stage(healthStageN, STAGE_TOTAL, 'Checking health...');
441
+ const result = await waitForComfyUI(endpointUrl, dep.deployment_id, config, chalk, {
442
+ costPerHour: dep.cost_per_hour || 0, stageLine,
443
+ });
429
444
 
430
445
  if (result.depFailed) {
431
446
  console.error(formatCliError('HEALTH_CHECK_DEPLOY_FAILED', {
432
447
  deploymentId: dep.deployment_id,
433
448
  failReason: result.failReason,
434
449
  }, chalk));
450
+ _printFailureClass(chalk, { failure_class: result.failureClass, next_action: result.nextAction });
435
451
  updateReceipt(rcptId, { status: 'failed', failReason: result.failReason });
436
452
  process.exitCode = 1;
437
453
  return;
438
454
  }
439
455
 
440
456
  endpointReady = result.ready;
441
- if (!endpointReady) updateReceipt(rcptId, { status: 'health_check_timeout' });
457
+ if (endpointReady) {
458
+ console.log(chalk.dim(_stage(healthStageN, STAGE_TOTAL, 'Checking health...')));
459
+ console.log(chalk.green(_stage(readyStageN, STAGE_TOTAL, 'Ready')));
460
+ } else {
461
+ updateReceipt(rcptId, { status: 'health_check_timeout' });
462
+ }
442
463
  }
443
464
 
444
465
  if (flags.checkNodes && endpointReady) {
@@ -460,18 +481,11 @@ export async function comfyuiCommand(config, args, chalk) {
460
481
  console.log();
461
482
  }
462
483
 
463
- const rate = dep.cost_per_hour || 0;
464
484
  console.log(` ${chalk.bold('URL:')} ${chalk.cyan(endpointUrl)}`);
465
- console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
466
- if (rate > 0) console.log(` ${chalk.bold('Rate:')} $${rate.toFixed(2)}/hr`);
467
485
  if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
468
486
  console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
469
487
  console.log(` ${chalk.bold('Logs:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
470
488
  console.log(` ${chalk.bold('Stop billing:')} ${chalk.cyan(`badgr down ${dep.deployment_id}`)}`);
471
489
  console.log();
472
-
473
- if (rate > HIGH_RATE_THRESHOLD && !flags.maxCost) {
474
- console.log(chalk.yellow(` Rate: $${rate.toFixed(2)}/hr — use --max-cost to cap total spend.\n`));
475
- }
476
490
  console.log(chalk.dim(' Billing continues until you run: ') + chalk.cyan(`badgr down ${dep.deployment_id}`));
477
491
  }
@@ -0,0 +1,58 @@
1
+ import path from 'path';
2
+ import { detectWorkload, workloadTypeLabel } from '../detect.js';
3
+
4
+ function fmtList(items, empty = 'none found') {
5
+ return items && items.length ? items.join(', ') : empty;
6
+ }
7
+
8
+ export function suggestedCommandLine(report, { save } = {}) {
9
+ const parts = ['badgr run .'];
10
+ if (report.command) parts.push(`--cmd "${report.command}"`);
11
+ if (report.outputs?.length) parts.push(`--output ${report.outputs[0]}`);
12
+ if (report.checkpoints?.length) parts.push(`--checkpoint ${report.checkpoints[0]}`);
13
+ parts.push('--max-cost 5');
14
+ if (save) parts.push(`--save ${save}`);
15
+ return parts.join(' ');
16
+ }
17
+
18
+ export function renderDetectReport(report, chalk) {
19
+ const lines = [];
20
+ lines.push('');
21
+ lines.push(chalk.bold(`Workload: ${workloadTypeLabel(report.workloadType)}`));
22
+ const confColor = report.confidence === 'high' ? chalk.green : report.confidence === 'medium' ? chalk.yellow : chalk.red;
23
+ lines.push(`Confidence: ${confColor(report.confidence)}`);
24
+ lines.push('');
25
+ lines.push(`Detected command: ${report.command ? chalk.cyan(report.command) : chalk.dim('(none — pass --cmd)')}`);
26
+ if (report.dockerfile) lines.push(`Detected image: Dockerfile (${report.dockerfile})`);
27
+ if (report.requirements.length) lines.push(`Detected requirements: ${fmtList(report.requirements)}`);
28
+ lines.push(`Detected inputs: ${fmtList(report.inputs)}`);
29
+ lines.push(`Detected outputs: ${fmtList(report.outputs)}`);
30
+ if (report.checkpoints.length) lines.push(`Detected checkpoints: ${fmtList(report.checkpoints)}`);
31
+ lines.push(`Detected models/assets: ${fmtList(report.models)}`);
32
+ if (report.ports.length) lines.push(`Detected ports: ${report.ports.join(', ')}`);
33
+ lines.push(`Estimated VRAM: ${report.vram}`);
34
+ lines.push(`Estimated runtime: ${report.runtimeEstimateMinutes != null ? `${report.runtimeEstimateMinutes} min` : 'until stopped (endpoint)'}`);
35
+ if (report.signals.length) {
36
+ lines.push('');
37
+ lines.push(chalk.dim(`Why: ${report.signals.join('; ')}`));
38
+ }
39
+ lines.push('');
40
+ lines.push(`Suggested Badgr command:`);
41
+ lines.push(` ${chalk.cyan(suggestedCommandLine(report))}`);
42
+ lines.push('');
43
+ return lines.join('\n');
44
+ }
45
+
46
+ export async function detectCommand(config, args, chalk) {
47
+ const target = args.find(a => !a.startsWith('--')) || '.';
48
+ const report = detectWorkload(target);
49
+
50
+ if (report.error) {
51
+ console.error(chalk.red(`\n ✗ ${report.error}: ${path.resolve(target)}\n`));
52
+ process.exitCode = 1;
53
+ return report;
54
+ }
55
+
56
+ console.log(renderDetectReport(report, chalk));
57
+ return report;
58
+ }
@@ -0,0 +1,38 @@
1
+ import { requireApiKey } from '../config.js';
2
+ import { findDeployment } from '../store.js';
3
+ import { heartbeatDeployment } from '../api.js';
4
+
5
+ /**
6
+ * badgr heartbeat <deployment-id|name>
7
+ *
8
+ * Resets an endpoint's idle-timeout clock. Badgr doesn't proxy inference
9
+ * traffic, so if you set --idle-timeout on `badgr serve`, call this on
10
+ * each real request (or wire it into your own client) — otherwise the
11
+ * endpoint is torn down once idle_timeout_minutes elapses, even if it's
12
+ * still reachable.
13
+ */
14
+ export async function heartbeatCommand(config, args, chalk) {
15
+ const idOrName = args.find(a => !a.startsWith('--'));
16
+
17
+ if (!idOrName) {
18
+ console.error(chalk.red('Usage: badgr heartbeat <deployment-id|name>'));
19
+ console.error(chalk.dim(' Resets the idle-timeout clock for an endpoint launched with --idle-timeout.'));
20
+ return;
21
+ }
22
+
23
+ requireApiKey(config);
24
+
25
+ const localDep = findDeployment(idOrName);
26
+ const deploymentId = localDep?.id ?? idOrName;
27
+
28
+ try {
29
+ const result = await heartbeatDeployment(config, deploymentId);
30
+ console.log(chalk.green(` ✓ Heartbeat recorded for ${deploymentId}`));
31
+ if (result.last_activity_at) {
32
+ console.log(chalk.dim(` last_activity_at: ${new Date(result.last_activity_at * 1000).toISOString()}`));
33
+ }
34
+ } catch (err) {
35
+ console.error(chalk.red(` ✗ Could not send heartbeat: ${err.message}`));
36
+ process.exitCode = 1;
37
+ }
38
+ }
@@ -1,5 +1,5 @@
1
- import { listReceipts as localReceipts } from '../store.js';
2
- import { listReceipts as apiReceipts, getReceipt as apiGetReceipt } from '../api.js';
1
+ import { listReceipts as localReceipts, findReceipt } from '../store.js';
2
+ import { listReceipts as apiReceipts, getReceipt as apiGetReceipt, getJobStatus } from '../api.js';
3
3
 
4
4
  function fmtMs(ms) {
5
5
  return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
@@ -54,6 +54,43 @@ export async function receiptsCommand(config, args, chalk) {
54
54
  console.log(chalk.dim(' Run `badgr login` to look up receipts from the API.\n'));
55
55
  return;
56
56
  }
57
+
58
+ // Job-family receipts (badgr comfyui batch / train lora / run / serve)
59
+ // mint a *local* receipt id that points at a job_id — the backend's
60
+ // /v1/receipts endpoint has never heard of it (that's the separate LLM
61
+ // inference receipt ledger), so look it up via the job's own status
62
+ // instead of 404ing against the wrong system.
63
+ const local = findReceipt(firstArg);
64
+ if (local?.job_id) {
65
+ try {
66
+ const job = await getJobStatus(config, local.job_id);
67
+ if (job.status === 'queued' || job.status === 'provisioning' || job.status === 'running' || job.status === 'tearing_down') {
68
+ console.log(chalk.yellow(' Receipt is still finalizing. Try again shortly.\n'));
69
+ console.log(` ${chalk.bold('Job ID:')} ${local.job_id} (status: ${job.status})\n`);
70
+ return;
71
+ }
72
+ printReceipt({
73
+ receiptId: firstArg,
74
+ action: local.type,
75
+ status: job.status,
76
+ created_at: new Date(local.started_at || Date.now()).toISOString(),
77
+ runtimeSeconds: job.elapsed_seconds,
78
+ finalCost: job.charged_usd ?? undefined,
79
+ failure_reason: job.failure_class,
80
+ }, chalk);
81
+ console.log(` ${chalk.bold('Teardown:')} ${job.teardown_status === 'ok' ? 'succeeded' : job.teardown_status === 'failed' ? 'failed' : 'n/a'}`);
82
+ console.log(` ${chalk.bold('Billing:')} ${job.billing_status === 'stopped' ? 'stopped' : 'running'}\n`);
83
+ } catch (err) {
84
+ // The job finished (we have a local record of a terminal receipt),
85
+ // but the backend can't confirm it right now — don't silently show
86
+ // nothing, since billing may or may not actually be settled.
87
+ console.log(chalk.yellow(' Billing stopped, but receipt finalization failed.\n'));
88
+ console.log(` ${chalk.bold('Job ID:')} ${local.job_id}`);
89
+ console.log(chalk.dim(' Contact support with this ID.\n'));
90
+ }
91
+ return;
92
+ }
93
+
57
94
  try {
58
95
  const r = await apiGetReceipt(config, firstArg);
59
96
  printReceipt(r, chalk);
@@ -0,0 +1,74 @@
1
+ import { requireApiKey } from '../config.js';
2
+ import { findDeployment, removeDeployment, addDeployment, addReceipt, generateReceiptId } from '../store.js';
3
+ import { restartDeployment } from '../api.js';
4
+
5
+ /**
6
+ * badgr restart <deployment-id|name>
7
+ *
8
+ * Tears down the current pod and relaunches an endpoint with the same
9
+ * config (GPU, model, price/cost/runtime caps, endpoint API key). Returns
10
+ * a *new* deployment_id and endpoint_url — the old pod's IP is gone.
11
+ */
12
+ export async function restartCommand(config, args, chalk) {
13
+ const idOrName = args.find(a => !a.startsWith('--'));
14
+
15
+ if (!idOrName) {
16
+ console.error(chalk.red('Usage: badgr restart <deployment-id|name>'));
17
+ return;
18
+ }
19
+
20
+ requireApiKey(config);
21
+
22
+ const localDep = findDeployment(idOrName);
23
+ const deploymentId = localDep?.id ?? idOrName;
24
+
25
+ process.stdout.write(chalk.dim(` Restarting ${deploymentId}...`));
26
+
27
+ let dep;
28
+ try {
29
+ dep = await restartDeployment(config, deploymentId);
30
+ } catch (err) {
31
+ process.stdout.write('\n');
32
+ console.error(chalk.red(`\n ✗ Could not restart deployment: ${err.message}\n`));
33
+ process.exitCode = 1;
34
+ return;
35
+ }
36
+
37
+ process.stdout.write('\n');
38
+
39
+ removeDeployment(idOrName);
40
+ addDeployment({
41
+ id: dep.deployment_id,
42
+ name: dep.name,
43
+ type: dep.workload_type,
44
+ model: dep.model,
45
+ gpu: dep.gpu_type,
46
+ count: dep.gpu_count,
47
+ status: dep.status,
48
+ endpointUrl: dep.endpoint_url || dep.openai_base_url,
49
+ receiptId: dep.receipt_id,
50
+ createdAt: new Date().toISOString(),
51
+ costPerHour: dep.cost_per_hour || 0,
52
+ providerRoute: dep.provider ?? null,
53
+ tier: dep.tier ?? null,
54
+ });
55
+
56
+ const rcptId = dep.receipt_id || generateReceiptId();
57
+ addReceipt({
58
+ receiptId: rcptId,
59
+ action: 'badgr restart',
60
+ deploymentId: dep.deployment_id,
61
+ gpu: dep.gpu_type,
62
+ status: dep.status,
63
+ createdAt: new Date().toISOString(),
64
+ });
65
+
66
+ const endpointUrl = dep.endpoint_url || dep.openai_base_url;
67
+ console.log(chalk.green('\n ✓ Restarted\n'));
68
+ console.log(` ${chalk.bold('New deployment:')} ${chalk.cyan(dep.deployment_id)}`);
69
+ if (endpointUrl) console.log(` ${chalk.bold('New base URL:')} ${chalk.cyan(endpointUrl)}`);
70
+ console.log(chalk.dim(' Old endpoint URL is gone — update any client pointed at it.'));
71
+ console.log(chalk.dim(' Your endpoint API key is unchanged — no need to re-issue it.'));
72
+ console.log(` ${chalk.bold('Status:')} badgr status ${dep.deployment_id}`);
73
+ console.log();
74
+ }