badgr-cli 1.1.4 → 1.1.6

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.
@@ -3,7 +3,7 @@ import { callApi, listDeployments } from '../api.js';
3
3
  import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../store.js';
4
4
  import { normalizeTier, callWithFallback } from '../fallback.js';
5
5
  import { formatCliError } from '../errors.js';
6
- import { requireApiKey } from '../config.js';
6
+ import { requireApiKey, webBaseUrl } from '../config.js';
7
7
  import { parseEnvFlag } from '../envFlag.js';
8
8
  import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides, BLESSED_VLLM_MODELS, isLikelyGatedModel } from '../catalog.js';
9
9
  import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass, printCapacityPreview, formatTierLabel } from '../progress.js';
@@ -65,11 +65,19 @@ function envObjHasHfToken(envList) {
65
65
  }
66
66
 
67
67
  // Extract a parameter-count-in-billions hint from a model/file name, e.g.
68
- // "Qwen2.5-0.5B-Instruct" → 0.5, "Llama-3.1-8B-Instruct" → 8, "Mixtral-8x7B" → 56.
68
+ // "Qwen2.5-0.5B-Instruct" → 0.5, "Llama-3.1-8B-Instruct" → 8, "Mixtral-8x7B" → 56,
69
+ // "facebook/opt-125m" → 0.125 (an "M" suffix means millions, not billions --
70
+ // omitting this previously meant every sub-1B-parameter model name came back
71
+ // with no size hint at all, silently falling through to the generic 7B-8B
72
+ // default below).
69
73
  // Splits into delimiter-bounded segments first so a version number like "2.5"
70
74
  // in "Qwen2.5-0.5B" is never mistaken for the param count — only a segment that
71
- // IS entirely "<digits>b" or "<digits>x<digits>b" counts as a size hint.
72
- function _extractParamsB(name) {
75
+ // IS entirely "<digits>b"/"<digits>m" or "<digits>x<digits>b" counts as a size
76
+ // hint. Mirrors backend/workload_profile.py's own `_extract_params_b` --
77
+ // keep the two in sync; a drift between them means this CLI preview and the
78
+ // backend's real GPU selection could show a different workload class for
79
+ // the same model.
80
+ export function _extractParamsB(name) {
73
81
  const s = name.toLowerCase();
74
82
  const segments = s.split(/[^a-z0-9.]+/).filter(Boolean);
75
83
  for (const seg of segments) {
@@ -80,16 +88,26 @@ function _extractParamsB(name) {
80
88
  const m = seg.match(/^(\d+(?:\.\d+)?)b$/);
81
89
  if (m) return parseFloat(m[1]);
82
90
  }
91
+ for (const seg of segments) {
92
+ const m = seg.match(/^(\d+(?:\.\d+)?)m$/);
93
+ if (m) return parseFloat(m[1]) / 1000;
94
+ }
83
95
  return null;
84
96
  }
85
97
 
86
98
  // Mirror of backend workload_profile.py infer_profile_from_model — for pre-flight display.
87
99
  // Sizing is only asserted when a param-count hint is found in the name; unknown
88
100
  // sizing falls back to the 7B–8B/24GB+ default rather than guessing small or large.
89
- function _inferServeProfile(modelName) {
101
+ // GPU lists match workload_profile.py's real preferred_gpus for each profile
102
+ // (not just a cosmetic display choice) -- "L4" previously appeared in the
103
+ // ≤3B bucket here but is not an actual GPU type this codebase ever routes to
104
+ // (see overflow_providers.py's GPU catalog), so it never matched anything
105
+ // real; the cheapest-available-first list below is the one the backend's
106
+ // own "inference_tiny" profile actually searches.
107
+ export function _inferServeProfile(modelName) {
90
108
  const paramsB = _extractParamsB(modelName);
91
109
  if (paramsB === null) return { label: 'inference (7B–8B model)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] };
92
- if (paramsB <= 3) return { label: 'inference (≤3B model)', vram: '8+ GB', gpus: ['RTX 3090', 'RTX 4090', 'L4'] };
110
+ if (paramsB <= 3) return { label: 'inference (≤3B model)', vram: '8+ GB', gpus: ['RTX 3080', 'RTX 3090', 'RTX 4090'] };
93
111
  if (paramsB <= 9) return { label: 'inference (7B–8B model)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] };
94
112
  if (paramsB <= 35) return { label: 'inference (30B–34B model)', vram: '40+ GB', gpus: ['A6000', 'L40S', 'A100'] };
95
113
  return { label: 'inference (70B+ model)', vram: '80+ GB', gpus: ['H100', 'A100'] };
@@ -133,7 +151,12 @@ function _resolveHealthPath({ healthPath, isLlamaCpp, customImage, task }) {
133
151
  * directly. The backend does the real health_path probing (see
134
152
  * DeploymentService.check_endpoint_readiness); a pod reporting RUNNING only
135
153
  * means infrastructure is up, not that the app inside is serving.
136
- * Returns { ready: boolean, timedOut: boolean, depFailed: boolean, failReason?: string }
154
+ * Returns { ready: boolean, timedOut: boolean, depFailed: boolean, failReason?: string,
155
+ * dep? }. `dep` (only present when ready) is the last polled
156
+ * /deployments/{id} response — carries supports_completions/
157
+ * supports_chat_completions, probed server-side once the endpoint first
158
+ * reports ready, so the caller's final usage example can match what the
159
+ * model actually serves instead of guessing.
137
160
  */
138
161
  // vLLM cold start (model download + load) often exceeds 5 min on first boot.
139
162
  const VLLM_SERVE_WAIT_MS = 15 * 60 * 1000;
@@ -165,7 +188,7 @@ async function waitForEndpoint(deploymentId, config, timeoutMs = VLLM_SERVE_WAIT
165
188
  }
166
189
  if (dep.endpoint_ready) {
167
190
  if (blockLines > 0) { _clearBlock(blockLines); blockLines = 0; }
168
- return { ready: true, timedOut: false, depFailed: false };
191
+ return { ready: true, timedOut: false, depFailed: false, dep };
169
192
  }
170
193
 
171
194
  const now = Date.now();
@@ -176,7 +199,7 @@ async function waitForEndpoint(deploymentId, config, timeoutMs = VLLM_SERVE_WAIT
176
199
  const spend = costPerHour * (elapsed / 3600);
177
200
 
178
201
  blockLines = _writeBlock(blockLines, _renderLiveBlock(chalk, {
179
- stageLine, elapsedSec: elapsed, statusWord, spend, id: deploymentId,
202
+ stageLine, elapsedSec: elapsed, statusWord, spend, id: deploymentId, maxCost,
180
203
  }));
181
204
  } catch {
182
205
  // status check failed (transient network/API issue) — retry next tick
@@ -428,16 +451,52 @@ export async function serveCommand(config, args, chalk) {
428
451
  return;
429
452
  }
430
453
 
431
- // Endpoints bill continuously — require explicit cost control.
454
+ // Endpoints bill continuously — require explicit cost control. In an
455
+ // interactive terminal, ask for it right here instead of forcing a whole
456
+ // re-run of the command — but never invent consent: a non-TTY context
457
+ // (CI, piped input) or a cancelled/empty prompt still hard-fails exactly
458
+ // as before, and provisioning never starts without an explicit answer.
432
459
  if (!flags.maxCost && !flags.persistent && !flags.dryRun) {
433
460
  const example = model || (customImage ? '--image ...' : '<model>');
434
- console.error(chalk.red('\n ✗ Endpoints bill continuously until stopped. Specify a spending limit:\n'));
435
- console.error(chalk.dim(` --max-cost 5 auto-stop when $5 is reached`));
436
- console.error(chalk.dim(` --persistent run until you stop it manually\n`));
437
- console.error(chalk.dim(` Example:`));
438
- console.error(chalk.dim(` badgr serve ${example} --max-cost 10\n`));
439
- process.exitCode = 1;
440
- return;
461
+ const failHard = () => {
462
+ console.error(chalk.red('\n ✗ Endpoints bill continuously until stopped. Specify a spending limit:\n'));
463
+ console.error(chalk.dim(` --max-cost 5 auto-stop when $5 is reached`));
464
+ console.error(chalk.dim(` --persistent run until you stop it manually\n`));
465
+ console.error(chalk.dim(` Example:`));
466
+ console.error(chalk.dim(` badgr serve ${example} --max-cost 10\n`));
467
+ process.exitCode = 1;
468
+ };
469
+
470
+ if (!process.stdin.isTTY) {
471
+ failHard();
472
+ return;
473
+ }
474
+
475
+ console.log(chalk.yellow('\n Endpoints bill continuously until stopped — no spending limit was given.'));
476
+ const { createInterface } = await import('readline');
477
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
478
+ const answer = await new Promise(resolve => {
479
+ rl.question(
480
+ chalk.dim(' Enter a max spend in USD to auto-stop, or "persistent" to run until you stop it manually [Ctrl+C to cancel]: $'),
481
+ resolve,
482
+ );
483
+ });
484
+ rl.close();
485
+
486
+ const trimmed = answer.trim();
487
+ if (/^p(ersistent)?$/i.test(trimmed)) {
488
+ flags.persistent = true;
489
+ console.log(chalk.dim(' Running persistent — stop billing with `badgr down`.\n'));
490
+ } else {
491
+ const parsed = parseFloat(trimmed);
492
+ if (!trimmed || !Number.isFinite(parsed) || parsed <= 0) {
493
+ console.log(chalk.dim('\n No valid answer given — not provisioning.\n'));
494
+ failHard();
495
+ return;
496
+ }
497
+ flags.maxCost = parsed;
498
+ console.log(chalk.dim(` Max spend set to $${parsed.toFixed(2)}.\n`));
499
+ }
441
500
  }
442
501
 
443
502
  const envObj = parseEnvFlag(flags.env);
@@ -580,6 +639,14 @@ export async function serveCommand(config, args, chalk) {
580
639
  };
581
640
  }
582
641
 
642
+ // `/serve` is a single synchronous call that blocks until routing,
643
+ // provisioning, and runtime startup have all either succeeded or failed
644
+ // server-side -- there is no live per-provider stage feed to poll during
645
+ // it (that would need an async two-phase API this endpoint doesn't have
646
+ // yet), so this is a plain pending indicator, not a progress bar.
647
+ console.log(chalk.dim(` Maximum spend: ${flags.maxCost ? `$${flags.maxCost.toFixed(2)}` : 'none (persistent)'}`));
648
+ console.log(chalk.dim(' Routing and provisioning...'));
649
+
583
650
  let dep;
584
651
  try {
585
652
  dep = await callWithFallback(
@@ -731,6 +798,11 @@ export async function serveCommand(config, args, chalk) {
731
798
  if (endpointReady) {
732
799
  console.log(chalk.dim(_stage(healthStageN, STAGE_TOTAL, 'Checking endpoint health...')));
733
800
  console.log(chalk.green(_stage(readyStageN, STAGE_TOTAL, 'Ready')));
801
+ if (healthResult.dep) {
802
+ dep.supports_completions = healthResult.dep.supports_completions;
803
+ dep.supports_chat_completions = healthResult.dep.supports_chat_completions;
804
+ dep.capability_source = healthResult.dep.capability_source;
805
+ }
734
806
  } else {
735
807
  updateReceipt(rcptId, { status: 'health_check_timeout' });
736
808
  }
@@ -758,7 +830,14 @@ export async function serveCommand(config, args, chalk) {
758
830
  console.log();
759
831
  }
760
832
 
761
- console.log(` ${chalk.bold('Base URL:')} ${chalk.cyan(endpointUrl)}`);
833
+ // dep.service_url is the Badgr-owned https://<id>.serve.aibadgr.com URL
834
+ // once that proxy is actually live (see backend/service_proxy.py) --
835
+ // until then it's absent/equal to endpointUrl, so this falls back to the
836
+ // raw provider URL exactly as before. Only the customer-facing display
837
+ // and usage examples switch to it; polling/comfy-node validation above
838
+ // still talk to the real internal endpointUrl directly.
839
+ const displayUrl = dep.service_url || endpointUrl;
840
+ console.log(` ${chalk.bold('Base URL:')} ${chalk.cyan(displayUrl)}`);
762
841
  if (isLlamaCpp) {
763
842
  console.log(` ${chalk.bold('HF Repo:')} ${flags.hfRepo}`);
764
843
  console.log(` ${chalk.bold('HF File:')} ${flags.hfFile}`);
@@ -767,6 +846,11 @@ export async function serveCommand(config, args, chalk) {
767
846
  if (customImage) console.log(` ${chalk.bold('Image:')} ${customImage}`);
768
847
  if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
769
848
  if (flags.idleTimeout) console.log(` ${chalk.bold('Idle timeout:')} ${flags.idleTimeout}m (auto-stops if idle — see Heartbeat below)`);
849
+ // A `serve` endpoint keeps running (and billing) after this command
850
+ // returns, unlike a finite `badgr run` job -- so this is the one place a
851
+ // management link matters: the customer needs a way back to it later
852
+ // that isn't re-running this exact command from shell history.
853
+ console.log(` ${chalk.bold('Manage:')} ${chalk.cyan(`${webBaseUrl(config)}/dashboard/jobs/${dep.deployment_id}`)}`);
770
854
  console.log(` ${chalk.bold('Logs:')} badgr logs ${dep.deployment_id}`);
771
855
  console.log(` ${chalk.bold('Restart:')} badgr restart ${dep.deployment_id}`);
772
856
  console.log(` ${chalk.bold('Stop billing:')} ${chalk.cyan(`badgr down ${dep.deployment_id}`)}`);
@@ -776,7 +860,7 @@ export async function serveCommand(config, args, chalk) {
776
860
 
777
861
  if (endpointReady && isOllama) {
778
862
  console.log(` ${chalk.bold('Test with curl:')}`);
779
- console.log(chalk.dim(` curl ${endpointUrl}/api/generate \\`));
863
+ console.log(chalk.dim(` curl ${displayUrl}/api/generate \\`));
780
864
  console.log(chalk.dim(` -H "Content-Type: application/json" \\`));
781
865
  console.log(chalk.dim(` -d '{"model":"${dep.model || effectiveModel}","prompt":"Hello","stream":false}'`));
782
866
  console.log();
@@ -793,13 +877,45 @@ export async function serveCommand(config, args, chalk) {
793
877
  console.log(` ${chalk.bold('API key:')} ${chalk.yellow(dep.endpoint_api_key)}`);
794
878
  console.log(chalk.dim(' Shown once — copy it now. This key is scoped to this endpoint only.'));
795
879
  }
880
+ // dep.supports_chat_completions is probed server-side (see backend
881
+ // model_capabilities.py): a live call against this exact endpoint is
882
+ // authoritative when it's reachable (dep.capability_source ===
883
+ // "runtime"); otherwise it falls back to a best-effort hint from the
884
+ // model's public HF chat-template config (capability_source ===
885
+ // "hf_hint"). `true`/`false` here always mean one of those two actually
886
+ // produced a real signal -- a model without a chat template (e.g.
887
+ // facebook/opt-125m) answers /v1/completions but 400s on
888
+ // /v1/chat/completions, so a generic chat example would be wrong.
889
+ // `undefined`/`null` means NEITHER produced a signal (auth-protected
890
+ // endpoint + HF lookup failed, or a non-vLLM/task image) -- unlike this
891
+ // command's old behavior, that must never silently default to showing
892
+ // a chat example that might not work; show neither example instead.
893
+ const noTask = !flags.task;
894
+ const chatKnown = dep.supports_chat_completions === true || dep.supports_chat_completions === false;
895
+ const showChatExample = noTask && chatKnown && dep.supports_chat_completions === true;
896
+ const showCompletionsExample = noTask && chatKnown && dep.supports_chat_completions === false;
897
+ const capabilityUnverified = noTask && !chatKnown;
898
+ const unconfirmedNote = dep.capability_source === 'hf_hint'
899
+ ? chalk.dim(' (based on the model\'s public Hugging Face config — not confirmed against this live endpoint)')
900
+ : null;
796
901
  console.log(` ${chalk.bold('Test with curl:')}`);
797
- console.log(chalk.dim(` curl ${endpointUrl}/chat/completions \\`));
798
- console.log(chalk.dim(` -H "Authorization: Bearer ${authKey}" -H "Content-Type: application/json" \\`));
799
- console.log(chalk.dim(` -d '{"model":"${sdkModel}","messages":[{"role":"user","content":"Hello"}]}'`));
902
+ if (showChatExample) {
903
+ console.log(chalk.dim(` curl ${displayUrl}/chat/completions \\`));
904
+ console.log(chalk.dim(` -H "Authorization: Bearer ${authKey}" -H "Content-Type: application/json" \\`));
905
+ console.log(chalk.dim(` -d '{"model":"${sdkModel}","messages":[{"role":"user","content":"Hello"}]}'`));
906
+ if (unconfirmedNote) console.log(unconfirmedNote);
907
+ } else if (showCompletionsExample) {
908
+ console.log(chalk.dim(` curl ${displayUrl}/completions \\`));
909
+ console.log(chalk.dim(` -H "Authorization: Bearer ${authKey}" -H "Content-Type: application/json" \\`));
910
+ console.log(chalk.dim(` -d '{"model":"${sdkModel}","prompt":"Hello","max_tokens":32}'`));
911
+ if (unconfirmedNote) console.log(unconfirmedNote);
912
+ } else if (capabilityUnverified) {
913
+ console.log(chalk.yellow(' Capability not verified yet — check available models first:'));
914
+ console.log(chalk.dim(` curl ${displayUrl}/models -H "Authorization: Bearer ${authKey}"`));
915
+ }
800
916
  console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
801
917
  console.log(chalk.dim(` from openai import OpenAI`));
802
- console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${authKey}")`));
918
+ console.log(chalk.dim(` client = OpenAI(base_url="${displayUrl}", api_key="${authKey}")`));
803
919
  if (flags.task === 'transcribe') {
804
920
  console.log(chalk.dim(` with open("audio.mp3", "rb") as f:`));
805
921
  console.log(chalk.dim(` t = client.audio.transcriptions.create(model="${sdkModel}", file=f, response_format="text")`));
@@ -808,8 +924,13 @@ export async function serveCommand(config, args, chalk) {
808
924
  console.log(chalk.dim(` # resp.data[0].b64_json contains the base64-encoded PNG`));
809
925
  } else if (flags.task === 'embed') {
810
926
  console.log(chalk.dim(` resp = client.embeddings.create(model="${sdkModel}", input=["hello world"])`));
811
- } else {
927
+ } else if (showChatExample) {
812
928
  console.log(chalk.dim(` resp = client.chat.completions.create(model="${sdkModel}", messages=[{"role": "user", "content": "Hello"}])`));
929
+ } else if (showCompletionsExample) {
930
+ console.log(chalk.dim(` resp = client.completions.create(model="${sdkModel}", prompt="Hello", max_tokens=32)`));
931
+ console.log(chalk.dim(` # this model has no chat template — /v1/chat/completions returns 400 for it`));
932
+ } else {
933
+ console.log(chalk.dim(` resp = client.models.list() # check capability_unverified — chat vs completions support isn't confirmed yet`));
813
934
  }
814
935
  console.log();
815
936
  if (flags.idleTimeout) {
@@ -1,11 +1,11 @@
1
1
  import { findCheapest, findById } from '../router.js';
2
- import { requireApiKey } from '../config.js';
2
+ import { ensureBadgrReady } from '../onboarding.js';
3
3
 
4
4
  export async function shellCommand(config, args, chalk) {
5
5
  const gpuFlag = args.indexOf('--gpu');
6
6
  const gpuId = gpuFlag !== -1 ? args[gpuFlag + 1] : null;
7
7
 
8
- requireApiKey(config);
8
+ config = await ensureBadgrReady(config, chalk);
9
9
 
10
10
  const gpu = gpuId ? findById(gpuId) : findCheapest({ tag: 'dev' });
11
11
 
@@ -1,9 +1,32 @@
1
1
  import { listDeployments as localDeployments } from '../store.js';
2
- import { listDeployments as apiDeployments } from '../api.js';
2
+ import { listDeployments as apiDeployments, listAllActiveDeploymentsAdmin } from '../api.js';
3
3
 
4
4
  export async function statusCommand(config, args, chalk) {
5
+ const isAdmin = args.includes('--admin');
5
6
  let deployments = [];
6
7
 
8
+ if (isAdmin) {
9
+ // Admin-only (michaelhireitem@gmail.com): every user's active
10
+ // deployments, not just the caller's own -- see
11
+ // deployment_routes.py's GET /v1/admin/deployments/active. A non-admin
12
+ // account gets a plain 403 here, same as any other access-denied call.
13
+ if (!config.apiKey) {
14
+ console.log(chalk.red('\n Sign in required: badgr login\n'));
15
+ return;
16
+ }
17
+ try {
18
+ const data = await listAllActiveDeploymentsAdmin(config);
19
+ deployments = data?.deployments ?? [];
20
+ } catch (err) {
21
+ console.log(chalk.red(`\n Could not fetch admin status: ${err.message}\n`));
22
+ return;
23
+ }
24
+ // Already active/non-terminal on arrival (the backend route only
25
+ // returns those) -- no client-side terminal-status filter needed here
26
+ // the way the plain (own-account) listing below has to apply.
27
+ return _printDeploymentList(deployments, chalk, { admin: true });
28
+ }
29
+
7
30
  if (config.apiKey) {
8
31
  try {
9
32
  const data = await apiDeployments(config);
@@ -46,25 +69,43 @@ export async function statusCommand(config, args, chalk) {
46
69
  const TERMINAL_STATUSES = new Set(['completed', 'succeeded', 'success', 'failed', 'stopped']);
47
70
  const running = deployments.filter(d => !TERMINAL_STATUSES.has(d.status));
48
71
 
49
- if (running.length === 0) {
50
- console.log(chalk.dim('\n Nothing running.\n'));
51
- console.log(chalk.dim(' badgr run python train.py'));
52
- console.log(chalk.dim(' badgr serve meta-llama/Llama-3.1-8B-Instruct\n'));
72
+ _printDeploymentList(running, chalk, { admin: false });
73
+ }
74
+
75
+ // Shared renderer for both the plain (own-account) and `--admin` (every
76
+ // account) listings -- the two only ever differed in: whether an owner
77
+ // line is shown, whether the printed `badgr down` command needs `--admin`
78
+ // appended, the header/empty-state wording, and the badge falling back to
79
+ // the raw status word instead of a fixed "starting" (the admin route can
80
+ // return any non-terminal status, not just "running"/"starting"). Factored
81
+ // into one function so those four differences are the only things that can
82
+ // diverge, instead of the whole render (badge/GPU/rate/URL/model/billing
83
+ // total/stop-commands) being copy-pasted per mode.
84
+ function _printDeploymentList(deployments, chalk, { admin }) {
85
+ if (deployments.length === 0) {
86
+ console.log(admin ? chalk.dim('\n Nothing running for any account.\n') : chalk.dim('\n Nothing running.\n'));
87
+ if (!admin) {
88
+ console.log(chalk.dim(' badgr run python train.py'));
89
+ console.log(chalk.dim(' badgr serve meta-llama/Llama-3.1-8B-Instruct\n'));
90
+ }
53
91
  return;
54
92
  }
55
93
 
56
- console.log(chalk.bold('\nRunning now:\n'));
57
- for (const d of running) {
94
+ console.log(chalk.bold(admin ? `\nRunning now (all accounts) — ${deployments.length} deployment(s):\n` : '\nRunning now:\n'));
95
+ for (const d of deployments) {
58
96
  const id = d.deployment_id || d.name;
59
97
  const type = d.workload_type === 'endpoint' ? 'endpoint' : 'job';
60
98
  const gpu = d.gpu_type || '—';
61
99
  const rate = d.cost_per_hour > 0 ? chalk.yellow(`$${d.cost_per_hour.toFixed(2)}/hr`) : '';
62
100
  const badge = d.status === 'running'
63
101
  ? chalk.green('● running')
64
- : chalk.yellow('● starting');
102
+ : chalk.yellow(admin ? `● ${d.status}` : '● starting');
65
103
 
66
104
  console.log(` ${badge} ${chalk.bold(id)} ${type} ${gpu} ${rate}`);
67
-
105
+ if (admin) {
106
+ const owner = d.username || d.user_email || d.user_id || 'unknown user';
107
+ console.log(` ${chalk.dim('User:')} ${chalk.cyan(owner)}`);
108
+ }
68
109
  if (d.workload_type === 'endpoint' && d.endpoint_url) {
69
110
  console.log(` ${chalk.dim('URL:')} ${d.endpoint_url}`);
70
111
  }
@@ -74,16 +115,19 @@ export async function statusCommand(config, args, chalk) {
74
115
  console.log();
75
116
  }
76
117
 
77
- const billable = running.filter(d => (d.cost_per_hour || 0) > 0);
118
+ const billable = deployments.filter(d => (d.cost_per_hour || 0) > 0);
78
119
  if (billable.length > 0) {
79
120
  const totalPerHr = billable.reduce((s, d) => s + (d.cost_per_hour || 0), 0);
80
- console.log(` ${chalk.bold('Total billing:')} ${chalk.yellow(`$${totalPerHr.toFixed(2)}/hr`)}\n`);
121
+ const label = admin ? 'Total billing (all accounts):' : 'Total billing:';
122
+ console.log(` ${chalk.bold(label)} ${chalk.yellow(`$${totalPerHr.toFixed(2)}/hr`)}\n`);
81
123
  }
82
124
 
83
125
  console.log(chalk.bold('Stop billing:\n'));
84
- for (const d of running) {
126
+ for (const d of deployments) {
85
127
  const id = d.deployment_id || d.name;
86
- console.log(` ${chalk.cyan(`badgr down ${id}`)}`);
128
+ const cmd = admin ? `badgr down ${id} --admin` : `badgr down ${id}`;
129
+ const ownerSuffix = admin ? ` ${chalk.dim(`(${d.username || d.user_email || d.user_id})`)}` : '';
130
+ console.log(` ${chalk.cyan(cmd)}${ownerSuffix}`);
87
131
  }
88
132
  console.log();
89
133
  }
@@ -1,4 +1,4 @@
1
- import { requireApiKey } from '../config.js';
1
+ import { ensureBadgrReady } from '../onboarding.js';
2
2
  import { callApi, terminateDeployment } from '../api.js';
3
3
  import { addReceipt, generateReceiptId } from '../store.js';
4
4
 
@@ -80,7 +80,7 @@ async function pollOutputOrDone(config, depId, expected, timeoutMs) {
80
80
  }
81
81
 
82
82
  export async function testCommand(config, args, chalk) {
83
- requireApiKey(config);
83
+ config = await ensureBadgrReady(config, chalk);
84
84
 
85
85
  const flags = parseTestArgs(Array.isArray(args) ? args : []);
86
86
  const providerKey = flags.provider ? flags.provider.toLowerCase() : 'tier1';
@@ -6,7 +6,7 @@
6
6
  * Enforces a max-runtime (default 120 min) and streams logs until completion.
7
7
  */
8
8
  import { readFileSync, existsSync } from 'fs';
9
- import { requireApiKey } from '../config.js';
9
+ import { ensureBadgrReady } from '../onboarding.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';
@@ -168,7 +168,7 @@ export async function trainLoraCommand(config, args, chalk) {
168
168
  return;
169
169
  }
170
170
 
171
- requireApiKey(config);
171
+ config = await ensureBadgrReady(config, chalk);
172
172
 
173
173
  // Build dataset input field
174
174
  const input = { base_model: flags.baseModel, config_preset: flags.preset || 'small' };
@@ -304,7 +304,7 @@ export async function trainCommand(config, args, chalk) {
304
304
  return;
305
305
  }
306
306
 
307
- requireApiKey(config);
307
+ config = await ensureBadgrReady(config, chalk);
308
308
 
309
309
  if (!existsSync(configFile)) {
310
310
  console.error(chalk.red(`\n ✗ Config file not found: ${configFile}\n`));
@@ -6,7 +6,7 @@
6
6
  * Transcript appears in streaming logs and is written to stdout by the container.
7
7
  */
8
8
  import { readFileSync, existsSync, statSync } from 'fs';
9
- import { requireApiKey } from '../config.js';
9
+ import { ensureBadgrReady } from '../onboarding.js';
10
10
  import { addReceipt, generateReceiptId } from '../store.js';
11
11
  import { normalizeTier, callWithFallback } from '../fallback.js';
12
12
  import { monitorBatchJob, fmtRuntime } from '../batch.js';
@@ -85,7 +85,7 @@ export async function transcribeCommand(config, args, chalk) {
85
85
  return;
86
86
  }
87
87
 
88
- requireApiKey(config);
88
+ config = await ensureBadgrReady(config, chalk);
89
89
 
90
90
  const resolved = resolveAudioInput(input);
91
91
  if (resolved.error) {
@@ -1,5 +1,6 @@
1
1
  import { parseSpec, validateSpec, specLines } from '../spec.js';
2
2
  import { requireApiKey } from '../config.js';
3
+ import { ensureBadgrReady } from '../onboarding.js';
3
4
  import { generateDeploymentId, generateReceiptId, addDeployment, addReceipt } from '../store.js';
4
5
  import { createDeployment, callApi } from '../api.js';
5
6
 
@@ -64,7 +65,7 @@ export async function upCommand(config, args, chalk) {
64
65
  }
65
66
 
66
67
  // ── Provision via backend API ─────────────────────────────────────────────
67
- requireApiKey(config);
68
+ config = await ensureBadgrReady(config, chalk);
68
69
 
69
70
  console.log(chalk.bold('\n🚀 Provisioning\n'));
70
71
  console.log(chalk.bold(' Spec'));
package/src/config.js CHANGED
@@ -29,6 +29,25 @@ export function normalizeBaseUrl(url) {
29
29
  return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`;
30
30
  }
31
31
 
32
+ const DEFAULT_WEB_URL = 'https://aibadgr.com';
33
+ const LOCAL_WEB_URL = 'http://localhost:3000';
34
+
35
+ // The frontend origin to link out to (Run page, evidence/repro page, etc).
36
+ // start-local.sh points config.baseUrl at http://localhost:8000/v1 so the
37
+ // CLI's API calls hit the local backend; when it does, links the CLI prints
38
+ // should point at the local frontend (localhost:3000) too, not production.
39
+ export function webBaseUrl(config) {
40
+ const envWeb = process.env.BADGR_WEB_URL?.trim().replace(/\/+$/, '');
41
+ if (envWeb) return envWeb;
42
+ try {
43
+ const { hostname } = new URL(config?.baseUrl || DEFAULTS.baseUrl);
44
+ if (hostname === 'localhost' || hostname === '127.0.0.1') return LOCAL_WEB_URL;
45
+ } catch {
46
+ // fall through to the default below
47
+ }
48
+ return DEFAULT_WEB_URL;
49
+ }
50
+
32
51
  function applyEnvOverrides(config) {
33
52
  const envBase = process.env.BADGR_API_URL?.trim();
34
53
  if (envBase) config.baseUrl = normalizeBaseUrl(envBase);
@@ -0,0 +1,45 @@
1
+ // Shared customer-facing filtering for `/deployments/{id}/logs` lines.
2
+ //
3
+ // The backend (DeploymentService.get_logs()) already keeps provider names,
4
+ // pod IDs, and raw provider status out of what it returns for a finite job
5
+ // once it's ready or finished -- the one exception is while readiness is
6
+ // still being actively probed (health_path set, not yet ready), where the
7
+ // same provider_status=/uptime= evidence is deliberately included, since
8
+ // that's exactly the window where "pod alive but app not answering" vs.
9
+ // "stuck in a crash loop" needs telling apart. This module is the
10
+ // client-side half either way: structured `[dep-...] field=value`
11
+ // meta-lines carry state the CLI renders elsewhere (status bar, SSH line),
12
+ // so they're suppressed from the printed log stream. Kept in one place so
13
+ // `badgr run`/`badgr launch` (live streaming) and `badgr logs` (fetch/follow)
14
+ // can't drift on which fields they hide.
15
+
16
+ // Structured `[dep-...] field=value` lines rendered elsewhere, not printed as log text.
17
+ export const DEPLOYMENT_META_LOG_RE =
18
+ /^\[dep-[^\]]+\] (status|gpu|region|endpoint|cost|receipt|provider_status|uptime)=/;
19
+
20
+ // Inline provider-status fields that can appear embedded in a line rather than
21
+ // as a whole `[dep-...] field=value` meta-line.
22
+ const INLINE_PROVIDER_FIELD_RE = /\b(gpu_util|cpu_util|provider_status|uptime)=/;
23
+
24
+ export function isMetaLogLine(line) {
25
+ return DEPLOYMENT_META_LOG_RE.test(line) || INLINE_PROVIDER_FIELD_RE.test(line);
26
+ }
27
+
28
+ export function isErrorLogLine(line) {
29
+ return /^error\b/i.test(line) || /Error response from daemon/i.test(line);
30
+ }
31
+
32
+ // Extract structured values from provider-status lines so the CLI can show
33
+ // them nicely (e.g. the SSH line in `badgr run`'s status bar).
34
+ export function parseProviderStatusLine(line) {
35
+ const gpuUtil = line.match(/\bgpu_util=([\d.]+)%/);
36
+ const cpuUtil = line.match(/\bcpu_util=([\d.]+)%/);
37
+ const ssh = line.match(/\bssh=(\S+)/);
38
+ const provSt = line.match(/\bprovider_status=(\S+)/);
39
+ return {
40
+ gpuUtil: gpuUtil ? parseFloat(gpuUtil[1]) : null,
41
+ cpuUtil: cpuUtil ? parseFloat(cpuUtil[1]) : null,
42
+ ssh: ssh ? ssh[1] : null,
43
+ providerStatus: provSt ? provSt[1] : null,
44
+ };
45
+ }
package/src/errors.js CHANGED
@@ -60,12 +60,16 @@ export const CATALOG = {
60
60
  ctx.failure_category === 'compat_failure'
61
61
  ? 'GPU/CUDA driver incompatibility — the container requires a CUDA version this GPU does not support.'
62
62
  : 'GPU was reserved but the container failed to start.',
63
- billing: 'never_started',
64
- retried: true,
63
+ // A real resource was reserved (the message says so) before this
64
+ // failed -- billing exposure was real, never "never_started". See
65
+ // fallback.js's own comment on why this code no longer triggers a
66
+ // client-side resubmission either.
67
+ billing: 'check_receipt',
68
+ retried: false,
65
69
  hint: (ctx) =>
66
70
  ctx.failure_category === 'compat_failure'
67
71
  ? ['Try a different base image or a different --gpu type.']
68
- : ['Badgr retried once. Please try again, or try a different --gpu type.'],
72
+ : ['Please try again, or try a different --gpu type.'],
69
73
  severity: 'P2',
70
74
  },
71
75