badgr-cli 1.1.5 → 1.1.7

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.
@@ -0,0 +1,250 @@
1
+ import os from 'os';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { execFileSync, spawn } from 'child_process';
6
+ import { ensureLoggedInReady } from '../onboarding.js';
7
+ import { connectNode, listNodes, inspectNode, disableNode, removeNode } from '../api.js';
8
+ import { detectGpus } from '../gpuDoctor/gpuInfo.js';
9
+
10
+ /**
11
+ * badgr node connect [--name <name>]
12
+ * badgr node list
13
+ * badgr node inspect <node>
14
+ * badgr node disable <node>
15
+ * badgr node remove <node>
16
+ *
17
+ * BYO GPU (Phase 1). `connect` registers this machine as a private capacity
18
+ * source (server-side) AND
19
+ * starts the badgr-node worker daemon (src/nodeWorker/node_worker.py,
20
+ * shipped inside this npm package) so the promised self-serve flow —
21
+ * `npm install -g badgr-cli && badgr node connect` — actually leaves a
22
+ * running worker, not just a database row. It never becomes
23
+ * marketplace-listed on its own.
24
+ */
25
+
26
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
+ const WORKER_SCRIPT = path.join(__dirname, '..', 'nodeWorker', 'node_worker.py');
28
+
29
+ /**
30
+ * Shared `--target <node>` resolution for `badgr run`/`badgr serve` — CLI
31
+ * sugar over the explicit customer_node target the API takes. Resolves a node name to its
32
+ * node_id; a bare `node_…` id is used as-is. Returns `{ ok: true, nodeId }`
33
+ * on success (`nodeId` is `undefined` when `target` is falsy — leaves the
34
+ * caller's body.target unset, i.e. today's cloud-provider search,
35
+ * unchanged) or `{ ok: false }` after printing a user-facing error, in
36
+ * which case the caller should set `process.exitCode = 1` and return.
37
+ */
38
+ export async function resolveTargetNodeId(config, target, chalk) {
39
+ if (!target) return { ok: true, nodeId: undefined };
40
+ if (target.startsWith('node_')) return { ok: true, nodeId: target };
41
+ let nodes;
42
+ try {
43
+ nodes = await listNodes(config);
44
+ } catch (err) {
45
+ console.error(chalk.red(`\n ✗ Could not resolve --target ${target}: ${err.message}\n`));
46
+ return { ok: false };
47
+ }
48
+ const match = nodes.find((n) => n.name === target || n.node_id === target);
49
+ if (!match) {
50
+ console.error(chalk.red(`\n ✗ No connected node named "${target}". Run \`badgr node list\`.\n`));
51
+ return { ok: false };
52
+ }
53
+ return { ok: true, nodeId: match.node_id };
54
+ }
55
+
56
+ function dockerAvailable() {
57
+ try {
58
+ execFileSync('docker', ['info'], { stdio: 'ignore', timeout: 5000 });
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ function python3Available() {
66
+ try {
67
+ execFileSync('python3', ['--version'], { stdio: 'ignore', timeout: 5000 });
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ function startWorker(config, node) {
75
+ const logDir = path.join(os.homedir(), '.badgr', 'logs');
76
+ fs.mkdirSync(logDir, { recursive: true });
77
+ const logPath = path.join(logDir, `node-${node.node_id}.log`);
78
+ const workerArgs = [
79
+ WORKER_SCRIPT, '--node-id', node.node_id, '--api-key', config.apiKey, '--base-url', config.baseUrl,
80
+ ];
81
+ // Header line makes it obvious in the log file which `connect` invocation
82
+ // spawned this worker run (helpful when a node is connected more than
83
+ // once, or the worker is restarted) without needing to inspect argv.
84
+ fs.appendFileSync(
85
+ logPath,
86
+ `\n=== ${new Date().toISOString()} badgr node connect: spawning worker for ${node.node_id} (base_url=${config.baseUrl}) ===\n`,
87
+ );
88
+ const out = fs.openSync(logPath, 'a');
89
+ const child = spawn('python3', workerArgs, { detached: true, stdio: ['ignore', out, out] });
90
+ child.unref();
91
+ return { pid: child.pid, logPath };
92
+ }
93
+
94
+ function detectHardware() {
95
+ const gpuProbe = detectGpus();
96
+ const primary = gpuProbe.gpus[0];
97
+ return {
98
+ gpu_vendor: gpuProbe.vendor,
99
+ gpu_model: primary ? primary.name : null,
100
+ gpu_count: gpuProbe.gpus.length,
101
+ vram_gb: primary ? primary.vramTotalGb : null,
102
+ // Feeds byo_preflight.run_preflight()'s required_gpu_architecture check
103
+ // (backend/byo_preflight.py) -- AMD reports a gfx#### string (e.g.
104
+ // "gfx1030"), NVIDIA reports its compute capability (e.g. "8.9"), both
105
+ // read straight off this machine's own GPU(s), never guessed.
106
+ gpu_architecture: primary ? primary.architecture : null,
107
+ driver_version: gpuProbe.driverVersion,
108
+ cuda_version: gpuProbe.cudaVersion,
109
+ cpu_count: os.cpus().length,
110
+ memory_gb: Math.round(os.totalmem() / (1024 ** 3)),
111
+ platform: os.platform(),
112
+ docker: dockerAvailable(),
113
+ };
114
+ }
115
+
116
+ function parseFlags(args) {
117
+ const flags = {};
118
+ let i = 0;
119
+ while (i < args.length) {
120
+ if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
121
+ i++;
122
+ }
123
+ return flags;
124
+ }
125
+
126
+ function resolveNodeIdArg(config, args) {
127
+ return args.find((a) => !a.startsWith('--'));
128
+ }
129
+
130
+ export async function nodeCommand(config, args, chalk) {
131
+ const sub = args[0];
132
+ const rest = args.slice(1);
133
+ config = await ensureLoggedInReady(config, chalk);
134
+
135
+ if (sub === 'connect') {
136
+ const flags = parseFlags(rest);
137
+ const hardware = detectHardware();
138
+ console.log(chalk.dim(
139
+ ` Detected: vendor=${hardware.gpu_vendor || 'none'} gpu_count=${hardware.gpu_count} gpu_model=${hardware.gpu_model || 'none'} `
140
+ + `arch=${hardware.gpu_architecture || 'none'} driver=${hardware.driver_version || 'none'} `
141
+ + `docker=${hardware.docker} platform=${hardware.platform}`,
142
+ ));
143
+ if (!hardware.gpu_count) {
144
+ console.log(chalk.yellow('\n No GPU detected (nvidia-smi and rocm-smi both not found or returned nothing).'));
145
+ console.log(chalk.dim(' BYO GPU currently supports Linux + NVIDIA or AMD/ROCm.\n'));
146
+ }
147
+ if (!hardware.docker) {
148
+ console.log(chalk.yellow(' Docker was not detected — badgr-node needs Docker to run workloads.\n'));
149
+ }
150
+ let node;
151
+ try {
152
+ node = await connectNode(config, { name: flags.name, hardware });
153
+ } catch (err) {
154
+ console.error(chalk.red(`\n ✗ Failed to connect node: ${err.message}\n`));
155
+ process.exit(1);
156
+ }
157
+ console.log(chalk.green(`\n ✓ Connected ${chalk.bold(node.name)} (${node.node_id})`));
158
+ console.log(chalk.dim(` ${hardware.gpu_count} × ${hardware.gpu_model || 'unknown GPU'} — private, not listed on the marketplace\n`));
159
+
160
+ const hasPython3 = python3Available();
161
+ if (!hardware.gpu_count || !hardware.docker || !hasPython3) {
162
+ const missing = [
163
+ !hardware.gpu_count && 'GPU (nvidia-smi/rocm-smi)',
164
+ !hardware.docker && 'Docker',
165
+ !hasPython3 && 'python3',
166
+ ].filter(Boolean).join(', ');
167
+ console.log(chalk.yellow(` Not starting the worker automatically — missing: ${missing}.`));
168
+ console.log(` Once fixed, start it with:`);
169
+ console.log(chalk.cyan(` python3 "${WORKER_SCRIPT}" --node-id ${node.node_id} --api-key <your-api-key>\n`));
170
+ return;
171
+ }
172
+
173
+ const { pid, logPath } = startWorker(config, node);
174
+ console.log(` Worker started (pid ${pid}) — this node is now receiving jobs.`);
175
+ console.log(chalk.dim(` Logs: ${logPath}\n`));
176
+ return;
177
+ }
178
+
179
+ if (sub === 'list') {
180
+ let nodes;
181
+ try {
182
+ nodes = await listNodes(config);
183
+ } catch (err) {
184
+ console.error(chalk.red(`\n ✗ Failed to list nodes: ${err.message}\n`));
185
+ process.exit(1);
186
+ }
187
+ if (!nodes.length) {
188
+ console.log(chalk.dim('\n No connected nodes. Run `badgr node connect` on a machine with an NVIDIA or AMD/ROCm GPU.\n'));
189
+ return;
190
+ }
191
+ console.log(chalk.bold('\nMy GPUs\n'));
192
+ for (const n of nodes) {
193
+ const status = n.online ? chalk.green('online') : chalk.dim('offline');
194
+ const hw = n.hardware || {};
195
+ console.log(` ${chalk.bold(n.name)} ${chalk.dim(n.node_id)}`);
196
+ console.log(` ${hw.gpu_count || 0} × ${hw.gpu_model || 'unknown'} ${status} ${n.visibility}`);
197
+ }
198
+ console.log();
199
+ return;
200
+ }
201
+
202
+ if (sub === 'inspect') {
203
+ const nodeId = resolveNodeIdArg(config, rest);
204
+ if (!nodeId) { console.error(chalk.red('\n Usage: badgr node inspect <node-id>\n')); process.exit(1); }
205
+ let node;
206
+ try {
207
+ node = await inspectNode(config, nodeId);
208
+ } catch (err) {
209
+ console.error(chalk.red(`\n ✗ ${err.message}\n`));
210
+ process.exit(1);
211
+ }
212
+ console.log(chalk.bold(`\n${node.name} ${chalk.dim(node.node_id)}\n`));
213
+ console.log(JSON.stringify(node, null, 2));
214
+ console.log();
215
+ return;
216
+ }
217
+
218
+ if (sub === 'disable') {
219
+ const nodeId = resolveNodeIdArg(config, rest);
220
+ if (!nodeId) { console.error(chalk.red('\n Usage: badgr node disable <node-id>\n')); process.exit(1); }
221
+ try {
222
+ await disableNode(config, nodeId);
223
+ } catch (err) {
224
+ console.error(chalk.red(`\n ✗ ${err.message}\n`));
225
+ process.exit(1);
226
+ }
227
+ console.log(chalk.green(`\n ✓ Disabled ${nodeId} — it will no longer receive new jobs.\n`));
228
+ return;
229
+ }
230
+
231
+ if (sub === 'remove') {
232
+ const nodeId = resolveNodeIdArg(config, rest);
233
+ if (!nodeId) { console.error(chalk.red('\n Usage: badgr node remove <node-id>\n')); process.exit(1); }
234
+ try {
235
+ await removeNode(config, nodeId);
236
+ } catch (err) {
237
+ console.error(chalk.red(`\n ✗ ${err.message}\n`));
238
+ process.exit(1);
239
+ }
240
+ console.log(chalk.green(`\n ✓ Removed ${nodeId}\n`));
241
+ return;
242
+ }
243
+
244
+ console.log(chalk.bold('\nbadgr node — manage your own connected GPUs\n'));
245
+ console.log(' badgr node connect [--name <name>] Connect this machine\'s GPU (private)');
246
+ console.log(' badgr node list List your connected nodes');
247
+ console.log(' badgr node inspect <node-id> Show one node\'s details');
248
+ console.log(' badgr node disable <node-id> Stop a node from receiving new jobs');
249
+ console.log(' badgr node remove <node-id> Disconnect and forget a node\n');
250
+ }
@@ -14,6 +14,7 @@ import { ensureBadgrReady } from '../onboarding.js';
14
14
  import { VM_CLASSES, parseGbSize } from '../spec.js';
15
15
  import { parseEnvFlag } from '../envFlag.js';
16
16
  import { isMetaLogLine, isErrorLogLine, parseProviderStatusLine } from '../deploymentLog.js';
17
+ import { resolveTargetNodeId } from './node.js';
17
18
 
18
19
  function vmClassLine(sizeKey) {
19
20
  const vmClass = VM_CLASSES[sizeKey];
@@ -54,6 +55,7 @@ export function parseRunArgs(args) {
54
55
  let i = 0;
55
56
  while (i < flagArgs.length) {
56
57
  if (flagArgs[i] === '--gpu') { flags.gpu = flagArgs[++i]; i++; continue; }
58
+ if (flagArgs[i] === '--target') { flags.target = flagArgs[++i]; i++; continue; }
57
59
  if (flagArgs[i] === '--image') { flags.image = flagArgs[++i]; i++; continue; }
58
60
  if (flagArgs[i] === '--count') { flags.count = parseInt(flagArgs[++i], 10); i++; continue; }
59
61
  if (flagArgs[i] === '--region') { flags.region = flagArgs[++i]; i++; continue; }
@@ -128,7 +130,7 @@ function redactEnvForDisplay(envList) {
128
130
  }).join(', ');
129
131
  }
130
132
 
131
- // Mirror of backend workload_profile.py — kept in sync for pre-flight display.
133
+ // Mirror of the backend's own workload-profile logic — kept in sync for pre-flight display.
132
134
  const _PROFILES = {
133
135
  smoke_test: { label: 'smoke test', vram: '4 GB', gpus: ['RTX 3080', 'RTX 3090', 'RTX 4090'] },
134
136
  lora_finetune: { label: 'fine-tuning (LoRA)', vram: '40+ GB', gpus: ['A6000', 'L40S', 'A100'] },
@@ -392,7 +394,7 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
392
394
 
393
395
  // Known badgr run flags — used to detect broken shell line continuation.
394
396
  const _KNOWN_RUN_FLAGS = new Set([
395
- '--gpu', '--image', '--count', '--region', '--tier', '--smoke', '--max-price', '--name',
397
+ '--gpu', '--target', '--image', '--count', '--region', '--tier', '--smoke', '--max-price', '--name',
396
398
  '--detach', '--no-detach', '--fallback', '--no-fallback', '--strict-capacity',
397
399
  '--no-expanded-search', '--max-runtime', '--max-cost', '--min-vram', '--gpu-memory',
398
400
  '--cpu', '--memory', '--no-gpu', '--env',
@@ -495,7 +497,7 @@ export async function runCommand(config, args, chalk, opts = {}) {
495
497
  // callers (badgr launch's agent workloads), never parsed from a shell
496
498
  // string. Passed to the backend as command_argv, which the worker always
497
499
  // prefers over the legacy cmd string + shlex.split() path (see
498
- // images/badgr-job-runner/entrypoint.py). flags.cmd stays the
500
+ // the worker image's own entrypoint). flags.cmd stays the
499
501
  // human-readable display string only.
500
502
  const cmdArgv = Array.isArray(opts.cmdArgv) ? opts.cmdArgv : undefined;
501
503
  const cmdName = isLaunch ? 'badgr launch' : 'badgr run';
@@ -903,6 +905,13 @@ export async function runCommand(config, args, chalk, opts = {}) {
903
905
  console.log(chalk.dim(` API: ${config.baseUrl}`));
904
906
  }
905
907
 
908
+ // --target <node> — see node.js's resolveTargetNodeId for the shared
909
+ // resolution (also used by serve.js). Omitting --target leaves
910
+ // body.target unset — today's cloud-provider search, unchanged.
911
+ const _targetResolved = await resolveTargetNodeId(config, flags.target, chalk);
912
+ if (!_targetResolved.ok) { process.exitCode = 1; return; }
913
+ const targetNodeId = _targetResolved.nodeId;
914
+
906
915
  function buildBody(gpuOverride, tierOverride) {
907
916
  const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
908
917
  return {
@@ -932,6 +941,7 @@ export async function runCommand(config, args, chalk, opts = {}) {
932
941
  ...(flags.artifacts?.length ? { output_paths: flags.artifacts } : {}),
933
942
  ...(flags.agentName ? { agent: flags.agentName } : {}),
934
943
  ...(flags.size ? { size: flags.size } : {}),
944
+ ...(targetNodeId ? { target: { type: 'customer_node', node_id: targetNodeId } } : {}),
935
945
  };
936
946
  }
937
947
 
@@ -999,7 +1009,7 @@ export async function runCommand(config, args, chalk, opts = {}) {
999
1009
  // Show what was actually provisioned, not just what was requested — only
1000
1010
  // when the caller asked for a resource floor and the provider reported
1001
1011
  // enough to say something concrete (best-effort; not every provider
1002
- // exposes vcpu/ram/vram per-offer, see deployment_service.py's
1012
+ // exposes vcpu/ram/vram per-offer, see the backend's own
1003
1013
  // chosen_instance.extra enrichment).
1004
1014
  if (flags.cpu || flags.memory || flags.minVram) {
1005
1015
  const parts = [];
@@ -7,6 +7,7 @@ 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';
10
+ import { resolveTargetNodeId } from './node.js';
10
11
 
11
12
  const LLAMA_CPP_IMAGE = 'michaelmanleyx/llama-cpp:server-cuda';
12
13
 
@@ -16,15 +17,38 @@ const LLAMA_CPP_IMAGE = 'michaelmanleyx/llama-cpp:server-cuda';
16
17
  * badgr serve BAAI/bge-large-en-v1.5 --task embed
17
18
  * badgr serve --image ghcr.io/my-org/diffusers-api:latest --gpu L40S --env MODEL_ID=flux
18
19
  * badgr serve --runtime llama.cpp --hf-repo org/repo --hf-file model.gguf --max-cost 10
20
+ * badgr serve org/Qwen3-27B-NVFP4 --vllm-arg --quantization=nvfp4 --vllm-arg --max-model-len=32768
21
+ * badgr serve org/Qwen3-27B-NVFP4 -- --quantization nvfp4 --max-model-len 32768
19
22
  *
20
23
  * GPU defaults to "AUTO" — backend infers from model size.
24
+ *
25
+ * --vllm-arg and the `--` passthrough form both feed the same extra_args
26
+ * list sent to POST /v1/serve, which appends them verbatim to vLLM's launch
27
+ * command after --model/--served-model-name (see
28
+ * the backend's own ServeBody.extra_args and
29
+ * cache_layer.build_vllm_launch). Only meaningful for the vLLM path (a
30
+ * HuggingFace --model, with or without --image) — ignored for --runtime
31
+ * llama.cpp and other managed runtimes, which don't build a vLLM command.
21
32
  */
22
33
  export function parseServeArgs(args) {
23
34
  const flags = {};
24
35
  const positional = [];
25
36
  let i = 0;
26
37
  while (i < args.length) {
38
+ if (args[i] === '--') {
39
+ // Everything after a bare `--` is passed through verbatim as vLLM
40
+ // launch args — same destination as repeated --vllm-arg, just
41
+ // without needing to prefix each one.
42
+ if (!flags.vllmArgs) flags.vllmArgs = [];
43
+ flags.vllmArgs.push(...args.slice(i + 1));
44
+ break;
45
+ }
46
+ if (args[i] === '--vllm-arg') {
47
+ if (!flags.vllmArgs) flags.vllmArgs = [];
48
+ flags.vllmArgs.push(args[++i]); i++; continue;
49
+ }
27
50
  if (args[i] === '--gpu') { flags.gpu = args[++i]; i++; continue; }
51
+ if (args[i] === '--target') { flags.target = args[++i]; i++; continue; }
28
52
  if (args[i] === '--image') { flags.image = args[++i]; i++; continue; }
29
53
  if (args[i] === '--task') { flags.task = args[++i]; i++; continue; }
30
54
  if (args[i] === '--count') { flags.count = parseInt(args[++i], 10); i++; continue; }
@@ -73,7 +97,7 @@ function envObjHasHfToken(envList) {
73
97
  // Splits into delimiter-bounded segments first so a version number like "2.5"
74
98
  // in "Qwen2.5-0.5B" is never mistaken for the param count — only a segment that
75
99
  // 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` --
100
+ // hint. Mirrors the backend's own parameter-extraction logic --
77
101
  // keep the two in sync; a drift between them means this CLI preview and the
78
102
  // backend's real GPU selection could show a different workload class for
79
103
  // the same model.
@@ -95,13 +119,13 @@ export function _extractParamsB(name) {
95
119
  return null;
96
120
  }
97
121
 
98
- // Mirror of backend workload_profile.py infer_profile_from_model — for pre-flight display.
122
+ // Mirror of the backend's own model-profile inference — for pre-flight display.
99
123
  // Sizing is only asserted when a param-count hint is found in the name; unknown
100
124
  // sizing falls back to the 7B–8B/24GB+ default rather than guessing small or large.
101
- // GPU lists match workload_profile.py's real preferred_gpus for each profile
125
+ // GPU lists match the backend's own preferred_gpus for each profile
102
126
  // (not just a cosmetic display choice) -- "L4" previously appeared in the
103
127
  // ≤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
128
+ // (see the backend's own GPU catalog), so it never matched anything
105
129
  // real; the cheapest-available-first list below is the one the backend's
106
130
  // own "inference_tiny" profile actually searches.
107
131
  export function _inferServeProfile(modelName) {
@@ -113,7 +137,7 @@ export function _inferServeProfile(modelName) {
113
137
  return { label: 'inference (70B+ model)', vram: '80+ GB', gpus: ['H100', 'A100'] };
114
138
  }
115
139
 
116
- // Mirror of backend workload_profile.py infer_profile_from_gguf.
140
+ // Mirror of the backend's own GGUF-profile inference.
117
141
  // Accepts the --hf-file filename; looks for param-count hints like "35B" or "8x7B".
118
142
  function _inferGgufProfile(ggufPath) {
119
143
  const paramsB = _extractParamsB(ggufPath);
@@ -151,7 +175,12 @@ function _resolveHealthPath({ healthPath, isLlamaCpp, customImage, task }) {
151
175
  * directly. The backend does the real health_path probing (see
152
176
  * DeploymentService.check_endpoint_readiness); a pod reporting RUNNING only
153
177
  * means infrastructure is up, not that the app inside is serving.
154
- * Returns { ready: boolean, timedOut: boolean, depFailed: boolean, failReason?: string }
178
+ * Returns { ready: boolean, timedOut: boolean, depFailed: boolean, failReason?: string,
179
+ * dep? }. `dep` (only present when ready) is the last polled
180
+ * /deployments/{id} response — carries supports_completions/
181
+ * supports_chat_completions, probed server-side once the endpoint first
182
+ * reports ready, so the caller's final usage example can match what the
183
+ * model actually serves instead of guessing.
155
184
  */
156
185
  // vLLM cold start (model download + load) often exceeds 5 min on first boot.
157
186
  const VLLM_SERVE_WAIT_MS = 15 * 60 * 1000;
@@ -183,7 +212,7 @@ async function waitForEndpoint(deploymentId, config, timeoutMs = VLLM_SERVE_WAIT
183
212
  }
184
213
  if (dep.endpoint_ready) {
185
214
  if (blockLines > 0) { _clearBlock(blockLines); blockLines = 0; }
186
- return { ready: true, timedOut: false, depFailed: false };
215
+ return { ready: true, timedOut: false, depFailed: false, dep };
187
216
  }
188
217
 
189
218
  const now = Date.now();
@@ -231,7 +260,7 @@ async function validateComfyNodes(baseUrl, nodeList, chalk) {
231
260
 
232
261
  // Known badgr serve flags — used to detect broken shell line continuation.
233
262
  const _KNOWN_SERVE_FLAGS = new Set([
234
- '--gpu', '--image', '--task', '--count', '--region', '--tier', '--max-price',
263
+ '--gpu', '--target', '--image', '--task', '--count', '--region', '--tier', '--max-price',
235
264
  '--name', '--no-wait', '--max-cost', '--idle-timeout', '--health-path', '--check-nodes',
236
265
  '--no-fallback', '--strict-capacity', '--no-expanded-search', '--env',
237
266
  '--persistent', '--yes', '-y', '--runtime', '--hf-repo', '--hf-file', '--dry-run',
@@ -536,6 +565,7 @@ export async function serveCommand(config, args, chalk) {
536
565
  if (flags.gpu) headerLines.push(['GPU', gpuLabel]);
537
566
  if (flags.task) headerLines.push(['Task', flags.task]);
538
567
  if (flags.env?.length) headerLines.push(['Env', flags.env.join(', ')]);
568
+ if (flags.vllmArgs?.length) headerLines.push(['vLLM args', flags.vllmArgs.join(' ')]);
539
569
  if (flags.dryRun) {
540
570
  headerLines.push(['Tier', formatTierLabel(effectiveTier)]);
541
571
  const previewHealthPath = _resolveHealthPath({ healthPath: flags.healthPath, isLlamaCpp, customImage, task: flags.task });
@@ -612,6 +642,13 @@ export async function serveCommand(config, args, chalk) {
612
642
 
613
643
  const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
614
644
 
645
+ // --target <node> — see node.js's resolveTargetNodeId for the shared
646
+ // resolution (also used by run.js). Omitting --target leaves body.target
647
+ // unset — today's cloud-provider search, unchanged.
648
+ const _targetResolved = await resolveTargetNodeId(config, flags.target, chalk);
649
+ if (!_targetResolved.ok) { process.exitCode = 1; return; }
650
+ const targetNodeId = _targetResolved.nodeId;
651
+
615
652
  function buildBody(gpuOverride, tierOverride) {
616
653
  const effectiveEnv = isLlamaCpp
617
654
  ? { LLAMA_ARG_HF_REPO: flags.hfRepo, LLAMA_ARG_HF_FILE: flags.hfFile, ...envObj }
@@ -631,6 +668,8 @@ export async function serveCommand(config, args, chalk) {
631
668
  ...(flags.maxCost ? { max_cost_usd: flags.maxCost } : {}),
632
669
  ...(flags.idleTimeout ? { idle_timeout_minutes: flags.idleTimeout } : {}),
633
670
  ...(flags.healthPath ? { health_path: flags.healthPath } : {}),
671
+ ...(flags.vllmArgs?.length ? { extra_args: flags.vllmArgs } : {}),
672
+ ...(targetNodeId ? { target: { type: 'customer_node', node_id: targetNodeId } } : {}),
634
673
  };
635
674
  }
636
675
 
@@ -793,6 +832,11 @@ export async function serveCommand(config, args, chalk) {
793
832
  if (endpointReady) {
794
833
  console.log(chalk.dim(_stage(healthStageN, STAGE_TOTAL, 'Checking endpoint health...')));
795
834
  console.log(chalk.green(_stage(readyStageN, STAGE_TOTAL, 'Ready')));
835
+ if (healthResult.dep) {
836
+ dep.supports_completions = healthResult.dep.supports_completions;
837
+ dep.supports_chat_completions = healthResult.dep.supports_chat_completions;
838
+ dep.capability_source = healthResult.dep.capability_source;
839
+ }
796
840
  } else {
797
841
  updateReceipt(rcptId, { status: 'health_check_timeout' });
798
842
  }
@@ -820,7 +864,14 @@ export async function serveCommand(config, args, chalk) {
820
864
  console.log();
821
865
  }
822
866
 
823
- console.log(` ${chalk.bold('Base URL:')} ${chalk.cyan(endpointUrl)}`);
867
+ // dep.service_url is the Badgr-owned https://<id>.serve.aibadgr.com URL
868
+ // once that proxy is actually live (server-side) --
869
+ // until then it's absent/equal to endpointUrl, so this falls back to the
870
+ // raw provider URL exactly as before. Only the customer-facing display
871
+ // and usage examples switch to it; polling/comfy-node validation above
872
+ // still talk to the real internal endpointUrl directly.
873
+ const displayUrl = dep.service_url || endpointUrl;
874
+ console.log(` ${chalk.bold('Base URL:')} ${chalk.cyan(displayUrl)}`);
824
875
  if (isLlamaCpp) {
825
876
  console.log(` ${chalk.bold('HF Repo:')} ${flags.hfRepo}`);
826
877
  console.log(` ${chalk.bold('HF File:')} ${flags.hfFile}`);
@@ -843,7 +894,7 @@ export async function serveCommand(config, args, chalk) {
843
894
 
844
895
  if (endpointReady && isOllama) {
845
896
  console.log(` ${chalk.bold('Test with curl:')}`);
846
- console.log(chalk.dim(` curl ${endpointUrl}/api/generate \\`));
897
+ console.log(chalk.dim(` curl ${displayUrl}/api/generate \\`));
847
898
  console.log(chalk.dim(` -H "Content-Type: application/json" \\`));
848
899
  console.log(chalk.dim(` -d '{"model":"${dep.model || effectiveModel}","prompt":"Hello","stream":false}'`));
849
900
  console.log();
@@ -860,13 +911,45 @@ export async function serveCommand(config, args, chalk) {
860
911
  console.log(` ${chalk.bold('API key:')} ${chalk.yellow(dep.endpoint_api_key)}`);
861
912
  console.log(chalk.dim(' Shown once — copy it now. This key is scoped to this endpoint only.'));
862
913
  }
914
+ // dep.supports_chat_completions is probed server-side (see backend
915
+ // the backend's own model-capabilities lookup): a live call against this exact endpoint is
916
+ // authoritative when it's reachable (dep.capability_source ===
917
+ // "runtime"); otherwise it falls back to a best-effort hint from the
918
+ // model's public HF chat-template config (capability_source ===
919
+ // "hf_hint"). `true`/`false` here always mean one of those two actually
920
+ // produced a real signal -- a model without a chat template (e.g.
921
+ // facebook/opt-125m) answers /v1/completions but 400s on
922
+ // /v1/chat/completions, so a generic chat example would be wrong.
923
+ // `undefined`/`null` means NEITHER produced a signal (auth-protected
924
+ // endpoint + HF lookup failed, or a non-vLLM/task image) -- unlike this
925
+ // command's old behavior, that must never silently default to showing
926
+ // a chat example that might not work; show neither example instead.
927
+ const noTask = !flags.task;
928
+ const chatKnown = dep.supports_chat_completions === true || dep.supports_chat_completions === false;
929
+ const showChatExample = noTask && chatKnown && dep.supports_chat_completions === true;
930
+ const showCompletionsExample = noTask && chatKnown && dep.supports_chat_completions === false;
931
+ const capabilityUnverified = noTask && !chatKnown;
932
+ const unconfirmedNote = dep.capability_source === 'hf_hint'
933
+ ? chalk.dim(' (based on the model\'s public Hugging Face config — not confirmed against this live endpoint)')
934
+ : null;
863
935
  console.log(` ${chalk.bold('Test with curl:')}`);
864
- console.log(chalk.dim(` curl ${endpointUrl}/chat/completions \\`));
865
- console.log(chalk.dim(` -H "Authorization: Bearer ${authKey}" -H "Content-Type: application/json" \\`));
866
- console.log(chalk.dim(` -d '{"model":"${sdkModel}","messages":[{"role":"user","content":"Hello"}]}'`));
936
+ if (showChatExample) {
937
+ console.log(chalk.dim(` curl ${displayUrl}/chat/completions \\`));
938
+ console.log(chalk.dim(` -H "Authorization: Bearer ${authKey}" -H "Content-Type: application/json" \\`));
939
+ console.log(chalk.dim(` -d '{"model":"${sdkModel}","messages":[{"role":"user","content":"Hello"}]}'`));
940
+ if (unconfirmedNote) console.log(unconfirmedNote);
941
+ } else if (showCompletionsExample) {
942
+ console.log(chalk.dim(` curl ${displayUrl}/completions \\`));
943
+ console.log(chalk.dim(` -H "Authorization: Bearer ${authKey}" -H "Content-Type: application/json" \\`));
944
+ console.log(chalk.dim(` -d '{"model":"${sdkModel}","prompt":"Hello","max_tokens":32}'`));
945
+ if (unconfirmedNote) console.log(unconfirmedNote);
946
+ } else if (capabilityUnverified) {
947
+ console.log(chalk.yellow(' Capability not verified yet — check available models first:'));
948
+ console.log(chalk.dim(` curl ${displayUrl}/models -H "Authorization: Bearer ${authKey}"`));
949
+ }
867
950
  console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
868
951
  console.log(chalk.dim(` from openai import OpenAI`));
869
- console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${authKey}")`));
952
+ console.log(chalk.dim(` client = OpenAI(base_url="${displayUrl}", api_key="${authKey}")`));
870
953
  if (flags.task === 'transcribe') {
871
954
  console.log(chalk.dim(` with open("audio.mp3", "rb") as f:`));
872
955
  console.log(chalk.dim(` t = client.audio.transcriptions.create(model="${sdkModel}", file=f, response_format="text")`));
@@ -875,8 +958,13 @@ export async function serveCommand(config, args, chalk) {
875
958
  console.log(chalk.dim(` # resp.data[0].b64_json contains the base64-encoded PNG`));
876
959
  } else if (flags.task === 'embed') {
877
960
  console.log(chalk.dim(` resp = client.embeddings.create(model="${sdkModel}", input=["hello world"])`));
878
- } else {
961
+ } else if (showChatExample) {
879
962
  console.log(chalk.dim(` resp = client.chat.completions.create(model="${sdkModel}", messages=[{"role": "user", "content": "Hello"}])`));
963
+ } else if (showCompletionsExample) {
964
+ console.log(chalk.dim(` resp = client.completions.create(model="${sdkModel}", prompt="Hello", max_tokens=32)`));
965
+ console.log(chalk.dim(` # this model has no chat template — /v1/chat/completions returns 400 for it`));
966
+ } else {
967
+ console.log(chalk.dim(` resp = client.models.list() # check capability_unverified — chat vs completions support isn't confirmed yet`));
880
968
  }
881
969
  console.log();
882
970
  if (flags.idleTimeout) {
@@ -1,19 +1,27 @@
1
1
  import { listDeployments as localDeployments } from '../store.js';
2
- import { listDeployments as apiDeployments, listAllActiveDeploymentsAdmin } from '../api.js';
2
+ import { listDeployments as apiDeployments } from '../api.js';
3
3
 
4
4
  export async function statusCommand(config, args, chalk) {
5
5
  const isAdmin = args.includes('--admin');
6
6
  let deployments = [];
7
7
 
8
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.
9
+ // Admin-only: every user's active deployments, not just the caller's
10
+ // own. Gated server-side by an internal admin allowlist -- a
11
+ // non-admin account gets a plain 403 here, same as any other
12
+ // access-denied call. See ../admin.js for why this is a separate,
13
+ // never-publicly-exported module.
13
14
  if (!config.apiKey) {
14
15
  console.log(chalk.red('\n Sign in required: badgr login\n'));
15
16
  return;
16
17
  }
18
+ let listAllActiveDeploymentsAdmin;
19
+ try {
20
+ ({ listAllActiveDeploymentsAdmin } = await import('../admin.js')); // oss-safe: guarded by try/catch, no-ops when admin.js is absent from a public export
21
+ } catch {
22
+ console.log(chalk.red('\n Admin mode is not available in this build.\n'));
23
+ return;
24
+ }
17
25
  try {
18
26
  const data = await listAllActiveDeploymentsAdmin(config);
19
27
  deployments = data?.deployments ?? [];
@@ -116,7 +116,7 @@ export function parseTrainLoraArgs(args) {
116
116
  return flags;
117
117
  }
118
118
 
119
- // Mirror of backend LORA_PRESETS (jobs_routes.py) — display only, server is authoritative.
119
+ // Mirror of the backend's own LORA_PRESETS — display only, server is authoritative.
120
120
  export const LORA_PRESET_INFO = {
121
121
  small: { gpu_type: 'RTX_4090', rank: 16, epochs: 3, description: 'Fast, low-cost — good default for most datasets' },
122
122
  medium: { gpu_type: 'A100', rank: 32, epochs: 5, description: 'Larger rank/more epochs — bigger datasets or higher quality' },
package/src/config.js CHANGED
@@ -29,25 +29,6 @@ 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
-
51
32
  function applyEnvOverrides(config) {
52
33
  const envBase = process.env.BADGR_API_URL?.trim();
53
34
  if (envBase) config.baseUrl = normalizeBaseUrl(envBase);
@@ -91,6 +72,18 @@ export function saveConfig(updates, configFile = CONFIG_FILE) {
91
72
  return merged;
92
73
  }
93
74
 
75
+ /**
76
+ * The Badgr dashboard's web origin. A localhost API base (local dev
77
+ * backend) maps to the local frontend dev server; every other API base
78
+ * (including a self-hosted/custom one) still points at the production
79
+ * dashboard, since that's the only place the dashboard is hosted today.
80
+ */
81
+ export function webBaseUrl(config) {
82
+ const base = normalizeBaseUrl(config?.baseUrl);
83
+ if (/^https?:\/\/localhost(?::\d+)?\//.test(base)) return 'http://localhost:3000';
84
+ return DEFAULTS.baseUrl.replace(/\/v1$/, '');
85
+ }
86
+
94
87
  export function requireApiKey(config) {
95
88
  if (!config.apiKey) throw new Error('No API key configured. Run: badgr login');
96
89
  return config.apiKey;