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.
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',
@@ -471,6 +497,21 @@ export const BLESSED_VLLM_MODELS = {
471
497
  },
472
498
  };
473
499
 
500
+ // Hugging Face org/model prefixes that are known to gate access behind a license
501
+ // click-through. Used only to print a helpful HF_TOKEN hint — not exhaustive.
502
+ const GATED_MODEL_PREFIXES = [
503
+ 'meta-llama/',
504
+ 'google/gemma',
505
+ 'mistralai/Mistral-Large',
506
+ 'mistralai/Mixtral-8x22B',
507
+ ];
508
+
509
+ /** Best-effort heuristic: is this HF model ID likely to require HF_TOKEN? */
510
+ export function isLikelyGatedModel(modelId) {
511
+ if (!modelId) return false;
512
+ return GATED_MODEL_PREFIXES.some(prefix => modelId.startsWith(prefix));
513
+ }
514
+
474
515
  /** Blessed ComfyUI workflows accepted by `POST /v1/jobs` comfy.batch. */
475
516
  export const BLESSED_COMFY_WORKFLOWS = {
476
517
  'sdxl-basic': {
@@ -478,6 +519,11 @@ export const BLESSED_COMFY_WORKFLOWS = {
478
519
  gpu_type: 'RTX_4090',
479
520
  output_type: 'images',
480
521
  },
522
+ 'flux-basic': {
523
+ description: 'FLUX.1-schnell text-to-image',
524
+ gpu_type: 'RTX_4090',
525
+ output_type: 'images',
526
+ },
481
527
  };
482
528
 
483
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
+ }
@@ -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);