badgr-cli 1.0.31 → 1.0.34

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.
@@ -1,13 +1,16 @@
1
1
  import { requireApiKey } from '../config.js';
2
2
  import { callApi } from '../api.js';
3
- import { addDeployment, addReceipt, generateReceiptId } from '../store.js';
3
+ import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../store.js';
4
+ import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
5
+ import { formatCliError } from '../errors.js';
4
6
 
5
7
  /**
8
+ * badgr serve meta-llama/Llama-3.1-8B-Instruct
6
9
  * badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S
10
+ * badgr serve BAAI/bge-large-en-v1.5 --task embed
11
+ * badgr serve --image ghcr.io/my-org/diffusers-api:latest --gpu L40S --env MODEL_ID=flux
7
12
  *
8
- * Provisions a persistent vLLM endpoint, health-checks it before printing
9
- * "Endpoint ready", and returns an OpenAI-compatible base URL.
10
- * Stop billing with `badgr down <id>`.
13
+ * GPU defaults to "AUTO" backend infers from model size.
11
14
  */
12
15
  export function parseServeArgs(args) {
13
16
  const flags = {};
@@ -15,82 +18,272 @@ export function parseServeArgs(args) {
15
18
  let i = 0;
16
19
  while (i < args.length) {
17
20
  if (args[i] === '--gpu') { flags.gpu = args[++i]; i++; continue; }
21
+ if (args[i] === '--image') { flags.image = args[++i]; i++; continue; }
22
+ if (args[i] === '--task') { flags.task = args[++i]; i++; continue; }
18
23
  if (args[i] === '--count') { flags.count = parseInt(args[++i], 10); i++; continue; }
19
24
  if (args[i] === '--region') { flags.region = args[++i]; i++; continue; }
25
+ if (args[i] === '--tier') { flags.tier = args[++i]; i++; continue; }
20
26
  if (args[i] === '--max-price') { flags.maxPrice = parseFloat(args[++i]); i++; continue; }
21
27
  if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
22
- if (args[i] === '--no-wait') { flags.noWait = true; i++; continue; }
28
+ if (args[i] === '--no-wait') { flags.noWait = true; i++; continue; }
29
+ if (args[i] === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
30
+ if (args[i] === '--health-path') { flags.healthPath = args[++i]; i++; continue; }
31
+ if (args[i] === '--check-nodes') { flags.checkNodes = args[++i]; i++; continue; }
32
+ // All three aliases map to noMarketplaceFallback
33
+ if (args[i] === '--no-fallback' ||
34
+ args[i] === '--strict-capacity' ||
35
+ args[i] === '--no-expanded-search') { flags.noMarketplaceFallback = true; i++; continue; }
36
+ if (args[i] === '--env') {
37
+ const kv = args[++i]; i++;
38
+ if (!flags.env) flags.env = [];
39
+ flags.env.push(kv);
40
+ continue;
41
+ }
23
42
  positional.push(args[i++]);
24
43
  }
25
44
  const model = positional[0] || null;
26
45
  return { model, flags };
27
46
  }
28
47
 
29
- // Poll GET <endpointUrl>/models until it returns 200 or timeout expires.
30
- // Returns true if healthy, false if timed out.
31
- async function waitForEndpoint(endpointUrl, timeoutMs = 5 * 60 * 1000, chalk) {
32
- const deadline = Date.now() + timeoutMs;
33
- const modelsUrl = `${endpointUrl}/models`;
34
- let attempt = 0;
48
+ function parseEnvFlag(envList) {
49
+ const obj = {};
50
+ for (const kv of (envList || [])) {
51
+ const idx = kv.indexOf('=');
52
+ if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
53
+ }
54
+ return obj;
55
+ }
56
+
57
+ // Mirror of backend workload_profile.py infer_profile_from_model — for pre-flight display.
58
+ function _inferServeProfile(modelName) {
59
+ const s = modelName.toLowerCase();
60
+ const moe = s.match(/(\d+)x(\d+)b/);
61
+ let paramsB;
62
+ if (moe) {
63
+ paramsB = parseInt(moe[1]) * parseInt(moe[2]);
64
+ } else {
65
+ const m = s.match(/(\d+)b/);
66
+ paramsB = m ? parseInt(m[1]) : null;
67
+ }
68
+ if (paramsB === null) return { label: 'inference (7B–8B model)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] };
69
+ if (paramsB <= 9) return { label: 'inference (7B–8B model)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] };
70
+ if (paramsB <= 35) return { label: 'inference (30B–34B model)', vram: '40+ GB', gpus: ['A6000', 'L40S', 'A100'] };
71
+ return { label: 'inference (70B+ model)', vram: '80+ GB', gpus: ['H100', 'A100'] };
72
+ }
73
+
74
+ function _serveStageLabel(elapsedSec, healthPath = '/models') {
75
+ if (healthPath === '/models') {
76
+ if (elapsedSec < 45) return 'Starting vLLM…';
77
+ if (elapsedSec < 150) return 'Downloading model…';
78
+ return 'Waiting for /v1/models…';
79
+ }
80
+ if (elapsedSec < 30) return 'Starting container…';
81
+ if (elapsedSec < 120) return 'Container starting…';
82
+ return `Waiting for ${healthPath}…`;
83
+ }
84
+
85
+ function _detectHealthPath(image) {
86
+ if (!image) return null;
87
+ return image.toLowerCase().includes('comfyui') ? '/system_stats' : null;
88
+ }
89
+
90
+ /**
91
+ * Poll the model endpoint until /models returns 200, or timeout.
92
+ * Also checks deployment status on each iteration — fails fast if the dep is dead.
93
+ * Returns { ready: boolean, timedOut: boolean, depFailed: boolean, failReason?: string }
94
+ */
95
+ async function waitForEndpoint(endpointUrl, deploymentId, config, timeoutMs = 5 * 60 * 1000, chalk, healthPath = '/models') {
96
+ const startMs = Date.now();
97
+ const deadline = startMs + timeoutMs;
35
98
 
36
99
  while (Date.now() < deadline) {
37
- attempt++;
100
+ // Check deployment status first — fail fast on OOM/crash before waiting more
101
+ try {
102
+ const dep = await callApi(`/deployments/${deploymentId}`, {
103
+ apiKey: config.apiKey,
104
+ baseUrl: config.baseUrl,
105
+ timeoutMs: 10_000,
106
+ });
107
+ if (['failed', 'terminated', 'error', 'stopped'].includes(dep.status)) {
108
+ process.stdout.write('\n');
109
+ return { ready: false, timedOut: false, depFailed: true, failReason: dep.error || dep.status };
110
+ }
111
+ } catch {
112
+ // status check failed — continue to endpoint check
113
+ }
114
+
38
115
  try {
39
- const res = await fetch(modelsUrl, { signal: AbortSignal.timeout(8000) });
40
- if (res.ok) return true;
116
+ const res = await fetch(`${endpointUrl}${healthPath}`, { signal: AbortSignal.timeout(8000) });
117
+ if (res.ok) { process.stdout.write('\n'); return { ready: true, timedOut: false, depFailed: false }; }
41
118
  } catch {
42
- // network not up yet — keep polling
119
+ // still starting
43
120
  }
44
- const elapsed = Math.round((Date.now() - (deadline - timeoutMs)) / 1000);
121
+
122
+ const elapsed = Math.round((Date.now() - startMs) / 1000);
45
123
  process.stdout.write(
46
- `\r ${chalk.dim(`Waiting for endpoint… ${elapsed}s (attempt ${attempt})`)} `
124
+ `\r ${chalk.dim(_serveStageLabel(elapsed, healthPath) + ` (${elapsed}s)`)} `
47
125
  );
48
126
  await new Promise(r => setTimeout(r, 8000));
49
127
  }
128
+
50
129
  process.stdout.write('\n');
51
- return false;
130
+ return { ready: false, timedOut: true, depFailed: false };
131
+ }
132
+
133
+ async function validateComfyNodes(baseUrl, nodeList, chalk) {
134
+ const nodes = nodeList.split(',').map(n => n.trim()).filter(Boolean);
135
+ if (!nodes.length) return;
136
+ const objectInfoUrl = baseUrl.replace(/\/v1\/?$/, '') + '/object_info';
137
+ console.log(chalk.dim(`\n Checking custom nodes via ${objectInfoUrl}…`));
138
+ try {
139
+ const res = await fetch(objectInfoUrl, { signal: AbortSignal.timeout(15_000) });
140
+ if (!res.ok) {
141
+ console.log(chalk.yellow(` ⚠ Could not verify nodes (HTTP ${res.status})`));
142
+ return;
143
+ }
144
+ const available = await res.json();
145
+ const found = nodes.filter(n => Object.prototype.hasOwnProperty.call(available, n));
146
+ const missing = nodes.filter(n => !Object.prototype.hasOwnProperty.call(available, n));
147
+ if (found.length) console.log(chalk.green(` ✓ Nodes confirmed: ${found.join(', ')}`));
148
+ if (missing.length) {
149
+ console.log(chalk.yellow(` ⚠ Nodes not found: ${missing.join(', ')}`));
150
+ console.log(chalk.dim(' These may not be installed in this image.'));
151
+ }
152
+ } catch (err) {
153
+ console.log(chalk.yellow(` ⚠ Node check failed: ${err.message}`));
154
+ }
52
155
  }
53
156
 
157
+ // Known badgr serve flags — used to detect broken shell line continuation.
158
+ const _KNOWN_SERVE_FLAGS = new Set([
159
+ '--gpu', '--image', '--task', '--count', '--region', '--tier', '--max-price',
160
+ '--name', '--no-wait', '--max-cost', '--health-path', '--check-nodes',
161
+ '--no-fallback', '--strict-capacity', '--no-expanded-search', '--env',
162
+ ]);
163
+
54
164
  export async function serveCommand(config, args, chalk) {
55
165
  const { model, flags } = parseServeArgs(args);
166
+ const customImage = flags.image || null;
56
167
 
57
- if (!model) {
58
- console.error(chalk.red('Usage: badgr serve <model> --gpu <type>'));
59
- console.error(chalk.red(' badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S'));
168
+ // Detect flags that ended up as positional args due to broken shell line continuation
169
+ // (e.g. `\ ` with a trailing space instead of `\<newline>`).
170
+ // After parseServeArgs, only the model name should be in positional. Any extra token
171
+ // that matches a known badgr flag name was not parsed — likely a syntax mistake.
172
+ const servePositional = args.filter(a => !a.startsWith('-'));
173
+ const misplaced = servePositional.slice(1).filter(a => _KNOWN_SERVE_FLAGS.has(a));
174
+ if (misplaced.length > 0) {
175
+ console.error(chalk.red(`\n ✗ These look like badgr flags but were treated as arguments:`));
176
+ console.error(chalk.red(` ${misplaced.join(', ')}`));
177
+ console.error(chalk.dim(''));
178
+ console.error(chalk.dim(' This usually means a line continuation has a trailing space.'));
179
+ console.error(chalk.dim(' Use a single line, or end each continued line with \\ and no space after:'));
180
+ console.error(chalk.dim(''));
181
+ console.error(chalk.dim(' badgr serve meta-llama/Llama-3.1-8B-Instruct \\'));
182
+ console.error(chalk.dim(' --gpu L40S --max-cost 10'));
183
+ console.error(chalk.dim(''));
184
+ process.exitCode = 1;
185
+ return;
186
+ }
187
+
188
+ if (!model && !customImage) {
189
+ console.error(chalk.red('Usage: badgr serve <model>'));
190
+ console.error(chalk.red(' badgr serve meta-llama/Llama-3.1-8B-Instruct'));
191
+ console.error(chalk.red(' badgr serve --image ghcr.io/my-org/api:latest --gpu L40S'));
60
192
  return;
61
193
  }
62
194
 
63
195
  requireApiKey(config);
64
196
 
65
- const gpu = flags.gpu || 'L40S';
197
+ // ── Validate flags early ───────────────────────────────────────────────────
198
+ if (flags.count !== undefined && (!Number.isFinite(flags.count) || flags.count < 1)) {
199
+ console.error(chalk.red(' ✗ --count must be an integer greater than 0'));
200
+ process.exitCode = 1;
201
+ return;
202
+ }
203
+ if (flags.maxCost !== undefined && (!Number.isFinite(flags.maxCost) || flags.maxCost <= 0)) {
204
+ console.error(chalk.red(' ✗ --max-cost must be a number greater than 0'));
205
+ process.exitCode = 1;
206
+ return;
207
+ }
208
+ if (flags.region !== undefined && !['US', 'EU', 'AU'].includes(flags.region.toUpperCase())) {
209
+ console.error(chalk.red(' ✗ --region must be US, EU, or AU'));
210
+ process.exitCode = 1;
211
+ return;
212
+ }
66
213
 
67
- console.log(chalk.bold('\n🚀 Serving model\n'));
68
- console.log(` ${chalk.bold('Model:')} ${model}`);
69
- console.log(` ${chalk.bold('GPU:')} ${gpu}`);
214
+ const envObj = parseEnvFlag(flags.env);
215
+
216
+ const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : (customImage ? 'L40S' : 'AUTO');
217
+ const gpuLabel = gpu === 'AUTO' ? 'auto' : gpu;
218
+
219
+ const effectiveTier = normalizeTier(flags.tier);
220
+
221
+ if (customImage) {
222
+ console.log(chalk.bold('\n⚡ Serving custom container\n'));
223
+ console.log(` ${chalk.bold('Image:')} ${customImage}`);
224
+ console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
225
+ if (flags.task) console.log(` ${chalk.bold('Task:')} ${flags.task}`);
226
+ if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
227
+ } else {
228
+ console.log(chalk.bold('\n⚡ Serving model\n'));
229
+ console.log(` ${chalk.bold('Model:')} ${model}`);
230
+ console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
231
+ if (flags.task) console.log(` ${chalk.bold('Task:')} ${flags.task}`);
232
+ if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
233
+
234
+ if (gpu === 'AUTO') {
235
+ const prof = _inferServeProfile(model);
236
+ console.log();
237
+ console.log(` ${chalk.bold('Estimated workload:')} ${prof.label}`);
238
+ console.log(` ${chalk.bold('Estimated minimum VRAM:')} ${prof.vram}`);
239
+ if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
240
+ }
241
+ }
70
242
  console.log();
71
- console.log(chalk.dim(' Finding best available GPU capacity...'));
243
+ process.stdout.write(chalk.dim(' Finding suitable capacity...\n'));
244
+
245
+ const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
246
+
247
+ function buildBody(gpuOverride, tierOverride) {
248
+ return {
249
+ ...(model ? { model } : {}),
250
+ ...(customImage ? { image: customImage } : {}),
251
+ ...(flags.task ? { task: flags.task } : {}),
252
+ gpu: gpuOverride || gpu,
253
+ gpu_count: flags.count || 1,
254
+ ...(effectiveRegion ? { region: effectiveRegion } : {}),
255
+ max_price_per_hour: flags.maxPrice,
256
+ name: flags.name,
257
+ tier: tierOverride || effectiveTier,
258
+ ...(Object.keys(envObj).length > 0 ? { env: envObj } : {}),
259
+ };
260
+ }
72
261
 
73
262
  let dep;
74
263
  try {
75
- dep = await callApi('/serve', {
76
- method: 'POST',
77
- apiKey: config.apiKey,
78
- baseUrl: config.baseUrl,
79
- body: {
80
- model,
81
- gpu: gpu.toUpperCase().replace('-', '_'),
82
- gpu_count: flags.count || 1,
83
- region: flags.region || 'US',
84
- max_price_per_hour: flags.maxPrice,
85
- name: flags.name,
86
- },
87
- });
264
+ dep = await callWithFallback(
265
+ '/serve',
266
+ { apiKey: config.apiKey, baseUrl: config.baseUrl },
267
+ (tierOverride) => buildBody(undefined, tierOverride),
268
+ effectiveTier,
269
+ chalk,
270
+ { thing: 'endpoint', cmd: 'badgr serve' },
271
+ { allowTier2Fallback: !flags.noMarketplaceFallback },
272
+ );
88
273
  } catch (err) {
89
- console.error(chalk.red(`\n ✗ Serve failed: ${err.message}\n`));
274
+ if (err.isPaymentRequired) {
275
+ console.error(chalk.yellow(err.message));
276
+ const rerun = `badgr serve ${args.join(' ')}`;
277
+ console.error(chalk.dim(`After payment, rerun:\n ${rerun}\n`));
278
+ process.exitCode = 1;
279
+ return;
280
+ }
281
+ // CapacityError message is pre-formatted with chalk
282
+ console.error(err.message);
283
+ process.exitCode = 1;
90
284
  return;
91
285
  }
92
286
 
93
- // Mirror to local store so badgr down/status/receipts work offline
94
287
  addDeployment({
95
288
  id: dep.deployment_id,
96
289
  name: dep.name,
@@ -98,12 +291,13 @@ export async function serveCommand(config, args, chalk) {
98
291
  model: dep.model || model,
99
292
  gpu: dep.gpu_type,
100
293
  count: dep.gpu_count,
101
- provider: dep.provider,
102
294
  status: dep.status,
103
295
  endpointUrl: dep.endpoint_url || dep.openai_base_url,
104
296
  receiptId: dep.receipt_id,
105
297
  createdAt: new Date().toISOString(),
106
298
  costPerHour: dep.cost_per_hour || 0,
299
+ providerRoute: dep.provider ?? null,
300
+ tier: dep.tier ?? null,
107
301
  });
108
302
 
109
303
  const rcptId = dep.receipt_id || generateReceiptId();
@@ -111,49 +305,132 @@ export async function serveCommand(config, args, chalk) {
111
305
  receiptId: rcptId,
112
306
  action: 'badgr serve',
113
307
  deploymentId: dep.deployment_id,
114
- provider: dep.provider,
115
308
  gpu: dep.gpu_type,
309
+ providerRoute: dep.provider ?? null,
310
+ tier: dep.tier ?? null,
116
311
  status: dep.status,
117
312
  createdAt: new Date().toISOString(),
118
313
  });
119
314
 
120
- const endpointUrl = dep.endpoint_url || dep.openai_base_url || config.baseUrl;
315
+ // ── Fix 7: never fall back to config.baseUrl for endpoint health check ─────
316
+ const endpointUrl = dep.endpoint_url || dep.openai_base_url;
317
+ if (!endpointUrl) {
318
+ console.error(chalk.red(
319
+ `\n ✗ Deployment ${dep.deployment_id} has no endpoint URL yet.\n` +
320
+ ` Run: badgr status ${dep.deployment_id}\n`
321
+ ));
322
+ updateReceipt(rcptId, { status: 'no_endpoint_url' });
323
+ process.exitCode = 1;
324
+ return;
325
+ }
121
326
 
122
- // ── Health check before declaring "ready" ─────────────────────────────────
123
- let endpointReady = false;
327
+ // ── Determine health check path ───────────────────────────────────────────
328
+ // Priority: explicit --health-path > auto-detect comfyui > /models for vLLM > null (skip)
329
+ let resolvedHealthPath;
330
+ if (flags.healthPath) {
331
+ resolvedHealthPath = flags.healthPath;
332
+ } else if (!customImage) {
333
+ resolvedHealthPath = '/models';
334
+ } else {
335
+ resolvedHealthPath = _detectHealthPath(customImage); // '/system_stats' for comfyui, null otherwise
336
+ }
124
337
 
338
+ // ── Health check ──────────────────────────────────────────────────────────
339
+ let endpointReady = false;
125
340
  if (flags.noWait) {
126
- console.log(chalk.yellow('\n Endpoint provisioning (skipped health check — use --no-wait)\n'));
341
+ console.log(chalk.yellow('\n Skipped health check (--no-wait)\n'));
342
+ } else if (resolvedHealthPath === null) {
343
+ console.log(chalk.yellow(
344
+ '\n Skipping health check (custom image — add --health-path /your-readiness-path to enable)\n'
345
+ ));
127
346
  } else {
128
- console.log(chalk.dim('\n Health-checking endpoint (up to 5 min)...'));
129
- endpointReady = await waitForEndpoint(endpointUrl, 5 * 60 * 1000, chalk);
347
+ if (resolvedHealthPath !== '/models') {
348
+ process.stdout.write(chalk.dim(` Checking ${resolvedHealthPath} for readiness…\n`));
349
+ }
350
+ // Check deployment status once before starting the 5-min wait
351
+ try {
352
+ const latest = await callApi(`/deployments/${dep.deployment_id}`, {
353
+ apiKey: config.apiKey,
354
+ baseUrl: config.baseUrl,
355
+ timeoutMs: 10_000,
356
+ });
357
+ if (['failed', 'terminated', 'error'].includes(latest.status)) {
358
+ console.error(formatCliError('HEALTH_CHECK_DEPLOY_FAILED', {
359
+ deploymentId: dep.deployment_id,
360
+ failReason: latest.error || latest.status,
361
+ }, chalk));
362
+ updateReceipt(rcptId, { status: 'failed', failReason: latest.error || latest.status });
363
+ process.exitCode = 1;
364
+ return;
365
+ }
366
+ } catch {
367
+ // status check failed — proceed with endpoint poll anyway
368
+ }
369
+
370
+ const healthResult = await waitForEndpoint(endpointUrl, dep.deployment_id, config, 5 * 60 * 1000, chalk, resolvedHealthPath);
130
371
  process.stdout.write('\n');
372
+
373
+ if (healthResult.depFailed) {
374
+ console.error(formatCliError('HEALTH_CHECK_DEPLOY_FAILED', {
375
+ deploymentId: dep.deployment_id,
376
+ failReason: healthResult.failReason,
377
+ }, chalk));
378
+ updateReceipt(rcptId, { status: 'failed', failReason: healthResult.failReason });
379
+ process.exitCode = 1;
380
+ return;
381
+ }
382
+
383
+ endpointReady = healthResult.ready;
384
+ if (!endpointReady) updateReceipt(rcptId, { status: 'health_check_timeout' });
385
+ }
386
+
387
+ // ── Custom-node validation (ComfyUI) ─────────────────────────────────────
388
+ if (flags.checkNodes && endpointReady) {
389
+ await validateComfyNodes(endpointUrl, flags.checkNodes, chalk);
131
390
  }
132
391
 
133
- // ── Print result ──────────────────────────────────────────────────────────
392
+ // ── Update receipt with final state ───────────────────────────────────────
393
+ updateReceipt(rcptId, {
394
+ status: endpointReady ? 'ready' : 'starting',
395
+ endpointUrl,
396
+ updatedAt: new Date().toISOString(),
397
+ });
398
+
399
+ // ── Result ────────────────────────────────────────────────────────────────
134
400
  if (endpointReady) {
135
- console.log(chalk.green('✓ Endpoint ready\n'));
401
+ console.log(chalk.green('\n ✓ Endpoint ready\n'));
136
402
  } else {
137
- console.log(chalk.yellow('⏳ Endpoint starting\n'));
138
- console.log(chalk.dim(` Not yet responding. Check status:`));
139
- console.log(chalk.dim(` badgr status`));
140
- console.log(chalk.dim(` curl ${endpointUrl}/models`));
403
+ console.log(chalk.yellow('\n ⏳ Endpoint still starting — model download may still be in progress.\n'));
404
+ console.log(` ${chalk.bold('Stop billing now:')} ${chalk.dim(`badgr down ${dep.deployment_id}`)}`);
405
+ console.log(` ${chalk.bold('Continue watching:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
141
406
  console.log();
142
407
  }
143
408
 
144
- console.log(` ${chalk.bold('Deployment:')} ${chalk.cyan(dep.deployment_id)}`);
145
- console.log(` ${chalk.bold('Model:')} ${dep.model || model}`);
146
- console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
147
- console.log(` ${chalk.bold('Endpoint:')} ${chalk.cyan(endpointUrl)}`);
148
- if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
149
- console.log(`\n ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
409
+ const serveRate = dep.cost_per_hour || 0;
150
410
 
151
- if (endpointReady) {
152
- console.log(`\n ${chalk.bold('Use with OpenAI SDK:')}`);
411
+ console.log(` ${chalk.bold('Base URL:')} ${chalk.cyan(endpointUrl)}`);
412
+ if (dep.model || model) console.log(` ${chalk.bold('Model:')} ${dep.model || model}`);
413
+ if (customImage) console.log(` ${chalk.bold('Image:')} ${customImage}`);
414
+ console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
415
+ if (serveRate > 0) console.log(` ${chalk.bold('Rate:')} $${serveRate.toFixed(2)}/hr`);
416
+ if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
417
+ console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
418
+ console.log(` ${chalk.bold('Logs:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
419
+ console.log(` ${chalk.bold('Stop billing:')} ${chalk.cyan(`badgr down ${dep.deployment_id}`)}`);
420
+ console.log();
421
+
422
+ if (serveRate > HIGH_RATE_THRESHOLD && !flags.maxCost) {
423
+ console.log(chalk.yellow(` Selected capacity rate: $${serveRate.toFixed(2)}/hr`));
424
+ console.log(chalk.dim(' Tip: use --max-cost to enforce a hard ceiling.\n'));
425
+ }
426
+ console.log(chalk.dim(' Billing continues until you run: ') + chalk.cyan(`badgr down ${dep.deployment_id}`));
427
+
428
+ if (endpointReady && !customImage) {
429
+ const keySnip = config.apiKey?.slice(0, 4) || 'sk-...';
430
+ console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
153
431
  console.log(chalk.dim(` from openai import OpenAI`));
154
- console.log(chalk.dim(` client = OpenAI(api_key="${config.apiKey?.slice(0, 8) || 'sk-...'}...", base_url="${endpointUrl}")`));
432
+ console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${keySnip}...")`));
155
433
  console.log(chalk.dim(` resp = client.chat.completions.create(model="${dep.model || model}", messages=[...])`));
434
+ console.log();
156
435
  }
157
-
158
- console.log(`\n ${chalk.dim(`Stop billing: badgr down ${dep.deployment_id}`)}\n`);
159
436
  }
@@ -2,24 +2,20 @@ import { listDeployments as localDeployments } from '../store.js';
2
2
  import { listDeployments as apiDeployments } from '../api.js';
3
3
 
4
4
  export async function statusCommand(config, args, chalk) {
5
- console.log(chalk.bold('\n📊 GPU Status\n'));
6
-
7
5
  let deployments = [];
8
6
 
9
- // ── Live data from the backend ────────────────────────────────────────────
10
7
  if (config.apiKey) {
11
8
  try {
12
9
  const data = await apiDeployments(config);
13
10
  deployments = data?.deployments ?? [];
14
11
  } catch (err) {
15
- console.log(chalk.yellow(` Could not reach API: ${err.message}. Showing local state.\n`));
12
+ console.log(chalk.yellow(`\n Could not reach API: ${err.message}. Showing local state.\n`));
16
13
  deployments = localDeployments().map(d => ({
17
14
  deployment_id: d.id,
18
15
  name: d.name,
19
16
  workload_type: d.type,
20
17
  gpu_type: d.gpu,
21
18
  gpu_count: d.count,
22
- provider: d.provider,
23
19
  status: d.status,
24
20
  cost_per_hour: d.costPerHour,
25
21
  endpoint_url: d.endpointUrl,
@@ -33,7 +29,6 @@ export async function statusCommand(config, args, chalk) {
33
29
  workload_type: d.type,
34
30
  gpu_type: d.gpu,
35
31
  gpu_count: d.count,
36
- provider: d.provider,
37
32
  status: d.status,
38
33
  cost_per_hour: d.costPerHour,
39
34
  endpoint_url: d.endpointUrl,
@@ -41,54 +36,46 @@ export async function statusCommand(config, args, chalk) {
41
36
  }));
42
37
  }
43
38
 
44
- if (deployments.length === 0) {
45
- console.log(chalk.dim(' No active deployments.'));
46
- console.log(chalk.dim(' Run `badgr serve <model> --gpu L40S` to provision one.\n'));
39
+ const running = deployments.filter(d => d.status === 'running' || d.status === 'provisioning');
40
+
41
+ if (running.length === 0) {
42
+ console.log(chalk.dim('\n Nothing running.\n'));
43
+ console.log(chalk.dim(' badgr run python train.py'));
44
+ console.log(chalk.dim(' badgr serve meta-llama/Llama-3.1-8B-Instruct\n'));
47
45
  return;
48
46
  }
49
47
 
50
- const cols = { name: 16, type: 9, gpu: 11, status: 14, price: 9 };
48
+ console.log(chalk.bold('\nRunning now:\n'));
49
+ for (const d of running) {
50
+ const id = d.deployment_id || d.name;
51
+ const type = d.workload_type === 'endpoint' ? 'endpoint' : 'job';
52
+ const gpu = d.gpu_type || '—';
53
+ const rate = d.cost_per_hour > 0 ? chalk.yellow(`$${d.cost_per_hour.toFixed(2)}/hr`) : '';
54
+ const badge = d.status === 'provisioning'
55
+ ? chalk.yellow('● starting')
56
+ : chalk.green('● running');
51
57
 
52
- const header =
53
- 'Name'.padEnd(cols.name) +
54
- 'Type'.padEnd(cols.type) +
55
- 'GPU'.padEnd(cols.gpu) +
56
- 'Status'.padEnd(cols.status) +
57
- 'Price/hr';
58
- console.log(' ' + chalk.bold(header));
59
- console.log(' ' + '─'.repeat(Object.values(cols).reduce((a, b) => a + b, 0) + 8));
58
+ console.log(` ${badge} ${chalk.bold(id)} ${type} ${gpu} ${rate}`);
60
59
 
61
- deployments.forEach(d => {
62
- const statusColor = d.status === 'running'
63
- ? chalk.green(d.status.padEnd(cols.status))
64
- : d.status === 'provisioning'
65
- ? chalk.yellow(d.status.padEnd(cols.status))
66
- : chalk.dim(d.status.padEnd(cols.status));
67
- const price = d.cost_per_hour > 0 ? `$${d.cost_per_hour.toFixed(2)}` : '—';
68
- const name = (d.name || d.deployment_id || '').slice(0, cols.name - 1);
69
- const gpu = (d.gpu_type || '—').slice(0, cols.gpu - 1).padEnd(cols.gpu);
70
- console.log(
71
- ' ' +
72
- name.padEnd(cols.name) +
73
- (d.workload_type || '—').padEnd(cols.type) +
74
- gpu +
75
- statusColor +
76
- price
77
- );
78
- });
60
+ if (d.workload_type === 'endpoint' && d.endpoint_url) {
61
+ console.log(` ${chalk.dim('URL:')} ${d.endpoint_url}`);
62
+ }
63
+ if (d.model) {
64
+ console.log(` ${chalk.dim('Model:')} ${d.model}`);
65
+ }
66
+ console.log();
67
+ }
79
68
 
80
- console.log();
69
+ const billable = running.filter(d => (d.cost_per_hour || 0) > 0);
70
+ if (billable.length > 0) {
71
+ const totalPerHr = billable.reduce((s, d) => s + (d.cost_per_hour || 0), 0);
72
+ console.log(` ${chalk.bold('Total billing:')} ${chalk.yellow(`$${totalPerHr.toFixed(2)}/hr`)}\n`);
73
+ }
81
74
 
82
- // Show endpoint URLs for running endpoint deployments
83
- const endpoints = deployments.filter(d => d.workload_type === 'endpoint' && d.status === 'running');
84
- if (endpoints.length > 0) {
85
- console.log(chalk.bold(' Endpoints\n'));
86
- endpoints.forEach(d => {
87
- const url = d.endpoint_url || config.baseUrl;
88
- console.log(` ${chalk.cyan(d.name || d.deployment_id)}`);
89
- console.log(` ${chalk.dim('URL:')} ${url}`);
90
- if (d.model) console.log(` ${chalk.dim('model:')} ${d.model}`);
91
- });
92
- console.log();
75
+ console.log(chalk.bold('Stop billing:\n'));
76
+ for (const d of running) {
77
+ const id = d.deployment_id || d.name;
78
+ console.log(` ${chalk.cyan(`badgr down ${id}`)}`);
93
79
  }
80
+ console.log();
94
81
  }