badgr-cli 1.0.26 → 1.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "badgr-cli",
3
- "version": "1.0.26",
3
+ "version": "1.0.27",
4
4
  "description": "Badgr, run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,19 +1,38 @@
1
- import { listModels } from '../api.js';
1
+ import { listModels, callApi } from '../api.js';
2
2
  import { listAll } from '../router.js';
3
3
 
4
4
  export async function modelsCommand(config, chalk) {
5
- const gpus = listAll();
6
-
7
5
  console.log(chalk.bold('\n📦 GPU Options (cheapest first)\n'));
6
+
7
+ let gpus = null;
8
+ if (config.apiKey) {
9
+ try {
10
+ const data = await callApi('/gpus', { apiKey: config.apiKey, baseUrl: config.baseUrl });
11
+ if (data?.gpus?.length) {
12
+ gpus = data.gpus.map(g => ({
13
+ id: g.id,
14
+ name: g.name,
15
+ vramGb: g.vram_gb,
16
+ ratePerHour: g.rate_per_hour,
17
+ })).sort((a, b) => a.ratePerHour - b.ratePerHour);
18
+ }
19
+ } catch {
20
+ // fallback to local catalog on error
21
+ }
22
+ }
23
+
24
+ if (!gpus) gpus = listAll();
25
+
8
26
  console.log(
9
- ` ${'ID'.padEnd(14)} ${'Name'.padEnd(24)} ${'VRAM'.padEnd(8)} Rate/hr`
27
+ ` ${'ID'.padEnd(14)} ${'Name'.padEnd(24)} ${'VRAM'.padEnd(8)} Rate/hr (indicative)`
10
28
  );
11
- console.log(` ${'─'.repeat(58)}`);
29
+ console.log(` ${'─'.repeat(68)}`);
12
30
  gpus.forEach(g => {
13
31
  console.log(
14
- ` ${chalk.cyan(g.id.padEnd(14))} ${g.name.padEnd(24)} ${`${g.vramGb}GB`.padEnd(8)} $${g.ratePerHour.toFixed(2)}`
32
+ ` ${chalk.cyan(g.id.padEnd(14))} ${g.name.padEnd(24)} ${`${g.vramGb}GB`.padEnd(8)} ~$${g.ratePerHour.toFixed(2)}`
15
33
  );
16
34
  });
35
+ console.log(chalk.dim(' Actual billing is set at job start — use `badgr run` to see live rates.'));
17
36
 
18
37
  console.log(chalk.bold('\n🤖 LLM Models\n'));
19
38
  if (!config.apiKey) {
@@ -89,7 +89,7 @@ async function waitForRunning(config, depId, chalk) {
89
89
  const TIMEOUT_MS = 5 * 60 * 1000; // 5 min startup grace (image pull + container init)
90
90
  const startMs = Date.now();
91
91
  const PHASES = [
92
- { afterMs: 0, label: ' Starting machine' },
92
+ { afterMs: 0, label: ' Starting container' },
93
93
  { afterMs: 15000, label: ' Pulling image' },
94
94
  { afterMs: 60000, label: ' Starting container' },
95
95
  { afterMs: 180000, label: ' Running command' },
@@ -285,63 +285,6 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
285
285
  }
286
286
  }
287
287
 
288
- /**
289
- * Infer a workload profile name from the command the user wants to run.
290
- * The backend uses this to enforce a VRAM floor when picking a GPU.
291
- *
292
- * Note: \b word boundaries are intentionally avoided for keyword checks
293
- * because keywords commonly appear inside filenames joined by underscores
294
- * (e.g. lora_train.py, run_sdxl.py) where `_` is a word character.
295
- */
296
- export function inferWorkload(command) {
297
- if (!command || command.length === 0) return 'general';
298
-
299
- const cmd = command.join(' ').toLowerCase();
300
-
301
- // Trivial one-liner / smoke test
302
- if (/print\s*\(|['"]hello/.test(cmd) && cmd.length < 80) return 'smoke_test';
303
-
304
- // LoRA / QLoRA / PEFT fine-tuning
305
- if (/lora|qlora|finetune|fine[_-]tun|peft/.test(cmd)) return 'lora_finetune';
306
-
307
- // Diffusion / image generation
308
- if (/diffusion|stable.?diff|sdxl|sd.?xl|comfyui|a1111|invoke|kohya/.test(cmd)) return 'image_gen';
309
-
310
- // vLLM / TGI / inference server
311
- if (/vllm|[^a-z]tgi[^a-z]|^tgi\b|tgi$|text.generation.inference/.test(cmd)) return 'inference_small';
312
-
313
- // Explicit training script
314
- if (/\btrain\.py\b/.test(cmd)) return 'lora_finetune';
315
-
316
- return 'general';
317
- }
318
-
319
- const WORKLOAD_LABELS = {
320
- smoke_test: 'smoke test',
321
- general: 'GPU job',
322
- lora_finetune: 'fine-tuning (40GB+ VRAM)',
323
- image_gen: 'image generation',
324
- inference_small: 'inference (7B–8B model)',
325
- inference_medium: 'inference (30B–34B model)',
326
- inference_large: 'inference (70B+ model)',
327
- };
328
-
329
- // Ask the backend for the best available GPU for a given workload.
330
- // Routes by: workload → min VRAM → provider tier → cheapest match.
331
- // Returns { gpu, region, price, workload, workload_desc } or null when nothing is available.
332
- async function findAutoGpu(config, chalk, tier = '1', workload = 'general') {
333
- try {
334
- const params = new URLSearchParams({ max_price: '10', tier, workload });
335
- return await callApi(`/capacity/auto?${params}`, {
336
- apiKey: config.apiKey,
337
- baseUrl: config.baseUrl,
338
- });
339
- } catch (err) {
340
- if (err.errorData?.code === 'NO_CAPACITY_MATCH') return null;
341
- throw err;
342
- }
343
- }
344
-
345
288
  function askConfirm(prompt) {
346
289
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
347
290
  return new Promise(resolve => rl.question(prompt, ans => { rl.close(); resolve(ans.trim()); }));
@@ -359,10 +302,10 @@ export async function runCommand(config, args, chalk) {
359
302
  requireApiKey(config);
360
303
 
361
304
  const command = positional.length > 0 ? positional : undefined;
362
- // Use alpine for smoke tests (7MB vs 50MB — much faster pull), slim for general workloads
363
- const inferredImage = (command && inferWorkload(command) === 'smoke_test')
364
- ? 'python:3.11-alpine'
365
- : 'python:3.11-slim';
305
+ // Use alpine for trivial one-liners (7MB vs 50MB — much faster pull)
306
+ const cmdStr = command ? command.join(' ') : '';
307
+ const isSmoke = cmdStr.length < 80 && /print\s*\(|['"]hello/i.test(cmdStr);
308
+ const inferredImage = isSmoke ? 'python:3.11-alpine' : 'python:3.11-slim';
366
309
  const image = flags.image || (command ? inferredImage : undefined);
367
310
  const detach = flags.detach || false;
368
311
  const fallbackMode = flags.noFallback ? 'none' : (flags.fallback || 'closest');
@@ -377,12 +320,8 @@ export async function runCommand(config, args, chalk) {
377
320
  // ── Auto GPU selection (no --gpu specified) ────────────────────────────────
378
321
  let gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : 'auto';
379
322
 
380
- // Infer workload from the command so the backend can apply the correct VRAM floor.
381
- const workload = command ? inferWorkload(command) : 'general';
382
- const workloadLabel = WORKLOAD_LABELS[workload] || 'GPU job';
383
-
384
323
  if (gpu === 'auto') {
385
- console.log(chalk.bold(`\nâš¡ Running ${workloadLabel}\n`));
324
+ console.log(chalk.bold('\nâš¡ Running GPU job\n'));
386
325
  if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
387
326
  if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
388
327
  if (effectiveTier === '2') console.log(` ${chalk.dim('(tier 2 — marketplace routing)')}`)
@@ -404,7 +343,7 @@ export async function runCommand(config, args, chalk) {
404
343
  }
405
344
  }
406
345
 
407
- console.log(chalk.dim(' Starting machine...'));
346
+ console.log(chalk.dim(' Finding reliable capacity...'));
408
347
  if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
409
348
  console.log(chalk.dim(` API: ${config.baseUrl}`));
410
349
  }
@@ -517,6 +456,7 @@ export async function runCommand(config, args, chalk) {
517
456
  console.log();
518
457
  console.log(chalk.bold(` Job ID: ${chalk.cyan(dep.deployment_id)}`));
519
458
  console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
459
+ if (dep.workload_desc) console.log(` ${chalk.bold('Workload:')} ${dep.workload_desc}`);
520
460
  if (dep.tier) console.log(` ${chalk.bold('Tier:')} ${dep.tier}`);
521
461
  if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
522
462
  console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
@@ -607,6 +547,11 @@ export async function runCommand(config, args, chalk) {
607
547
  console.log();
608
548
  process.exit(exitCode ?? 1);
609
549
  } else if ((finalStatus === 'completed' || finalStatus === 'succeeded') && (exitCode === 0 || exitCode === null)) {
610
- console.log(chalk.green(`\n ✓ Job complete\n`));
550
+ try {
551
+ await terminateDeployment(config, dep.deployment_id);
552
+ } catch { /* already stopped */ }
553
+ console.log(chalk.green(`\n ✓ Complete`));
554
+ console.log(chalk.dim(` Billing ended`));
555
+ console.log();
611
556
  }
612
557
  }
@@ -84,7 +84,7 @@ export async function serveCommand(config, args, chalk) {
84
84
  console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
85
85
  if (effectiveTier === '2') console.log(` ${chalk.dim('(tier 2 — marketplace routing)')}`);
86
86
  console.log();
87
- process.stdout.write(chalk.dim(' Finding GPU capacity...\n'));
87
+ process.stdout.write(chalk.dim(' Finding reliable capacity...\n'));
88
88
 
89
89
  const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
90
90
 
@@ -2,8 +2,8 @@ import { requireApiKey } from '../config.js';
2
2
  import { callApi, terminateDeployment } from '../api.js';
3
3
  import { addReceipt, generateReceiptId } from '../store.js';
4
4
 
5
- // max $1.50/hr × 2 min ≈ $0.05 total spend cap
6
- const TEST_MAX_PRICE = 1.50;
5
+ // max $0.80/hr × 2 min ≈ $0.027 total spend cap (smoke / Modal T4 tier)
6
+ const TEST_MAX_PRICE = 0.80;
7
7
  const TEST_MAX_RUNTIME_MS = 2 * 60 * 1000;
8
8
  const TEST_COMMAND = ['python', '-c', "print('hello from badgr')"];
9
9
  // Use alpine (7MB) instead of slim (50MB) — dramatically faster image pull for smoke tests.
@@ -20,6 +20,7 @@ export function parseTestArgs(args) {
20
20
  let i = 0;
21
21
  while (i < args.length) {
22
22
  if (args[i] === '--provider' && args[i + 1]) { flags.provider = args[++i]; i++; continue; }
23
+ if (args[i] === '--no-tier-fallback') { flags.noTierFallback = true; i++; continue; }
23
24
  i++;
24
25
  }
25
26
  return flags;
@@ -46,20 +47,36 @@ async function pollStatus(config, depId, targetStatuses, timeoutMs) {
46
47
  return null;
47
48
  }
48
49
 
49
- async function pollLogs(config, depId, expected, timeoutMs) {
50
+ async function pollOutputOrDone(config, depId, expected, timeoutMs) {
50
51
  const deadline = Date.now() + timeoutMs;
51
52
  while (Date.now() < deadline) {
52
- await new Promise(r => setTimeout(r, 4000));
53
+ await new Promise(r => setTimeout(r, 3000));
53
54
  try {
55
+ const dep = await callApi(`/deployments/${depId}`, {
56
+ apiKey: config.apiKey,
57
+ baseUrl: config.baseUrl,
58
+ });
59
+ if (dep.status === 'succeeded' || dep.status === 'failed') {
60
+ const data = await callApi(`/deployments/${depId}/logs`, {
61
+ apiKey: config.apiKey,
62
+ baseUrl: config.baseUrl,
63
+ });
64
+ const lines = data?.logs ?? [];
65
+ return {
66
+ done: true,
67
+ ok: dep.status === 'succeeded' && lines.some(l => l.includes(expected)),
68
+ exitCode: dep.exit_code,
69
+ };
70
+ }
54
71
  const data = await callApi(`/deployments/${depId}/logs`, {
55
72
  apiKey: config.apiKey,
56
73
  baseUrl: config.baseUrl,
57
74
  });
58
75
  const lines = data?.logs ?? [];
59
- if (lines.some(l => l.includes(expected))) return true;
76
+ if (lines.some(l => l.includes(expected))) return { done: true, ok: true, exitCode: 0 };
60
77
  } catch { /* retry */ }
61
78
  }
62
- return false;
79
+ return { done: false, ok: false, exitCode: null };
63
80
  }
64
81
 
65
82
  export async function testCommand(config, args, chalk) {
@@ -119,7 +136,7 @@ export async function testCommand(config, args, chalk) {
119
136
  body: { ...baseBody, tier },
120
137
  });
121
138
  } catch (err) {
122
- if (err.errorData?.code === 'NO_CAPACITY_MATCH' && tier === '1') {
139
+ if (err.errorData?.code === 'NO_CAPACITY_MATCH' && tier === '1' && !flags.noTierFallback) {
123
140
  process.stdout.write('\n');
124
141
  process.stdout.write(chalk.dim(' No tier 1 capacity — trying tier 2 marketplace routing...'));
125
142
  try {
@@ -136,6 +153,12 @@ export async function testCommand(config, args, chalk) {
136
153
  console.error(chalk.red(' Test failed — no GPU capacity available on any provider.\n'));
137
154
  process.exit(1);
138
155
  }
156
+ } else if (err.errorData?.code === 'NO_CAPACITY_MATCH' && flags.noTierFallback) {
157
+ process.stdout.write('\n');
158
+ step(chalk, false, 'Provisioned', 'no tier 1 capacity');
159
+ console.log();
160
+ console.error(chalk.red(' Test failed — no tier 1 capacity (strict mode, no tier 2 fallback).\n'));
161
+ process.exit(1);
139
162
  } else {
140
163
  process.stdout.write('\n');
141
164
  step(chalk, false, 'Provisioned', err.message);
@@ -168,10 +191,13 @@ export async function testCommand(config, args, chalk) {
168
191
 
169
192
  // ── 3. Command output ────────────────────────────────────────────────────
170
193
  process.stdout.write(chalk.dim(' Checking command output...'));
171
- const gotOutput = await pollLogs(config, depId, EXPECTED_OUTPUT, 90_000);
194
+ const outputResult = await pollOutputOrDone(config, depId, EXPECTED_OUTPUT, 90_000);
172
195
  process.stdout.write('\n');
196
+ const gotOutput = outputResult.ok;
173
197
  if (gotOutput) {
174
198
  step(chalk, true, 'Command printed output');
199
+ } else if (outputResult.done && outputResult.exitCode !== 0) {
200
+ step(chalk, false, 'Command printed output', `exit ${outputResult.exitCode}`);
175
201
  } else {
176
202
  step(chalk, false, 'Command printed output', 'not found in logs (logs may be buffered)');
177
203
  }
package/src/router.js CHANGED
@@ -1,20 +1,24 @@
1
1
  /**
2
- * GPU catalog — static specs only (name, VRAM, tags, indicative rate).
2
+ * GPU catalog — static display info only (name, VRAM, tags, indicative rate).
3
3
  *
4
- * Provider-level pricing and routing logic have been removed from the CLI.
5
- * Live routing decisions happen server-side; the CLI is a thin display layer.
6
- * For live availability and routing, use GET /v1/capacity/debug (operators)
7
- * or GET /v1/capacity/suggestions (users).
4
+ * ratePerHour values are indicative market averages for reference display.
5
+ * Actual billing is always set by the backend and returned as cost_per_hour
6
+ * on every deployment response. For live rates, use GET /v1/gpus.
7
+ *
8
+ * Provider routing logic lives entirely in the backend.
8
9
  */
9
10
 
10
11
  export const GPU_CATALOG = [
11
- { id: 'rtx-3080', canonical: 'RTX_3080', name: 'NVIDIA RTX 3080', vramGb: 10, ratePerHour: 0.35, tags: ['inference', 'dev'] },
12
- { id: 'rtx-4090', canonical: 'RTX_4090', name: 'NVIDIA RTX 4090', vramGb: 24, ratePerHour: 1.10, tags: ['inference', 'training', 'dev'] },
13
- { id: 'l40s', canonical: 'L40S', name: 'NVIDIA L40S', vramGb: 48, ratePerHour: 1.40, tags: ['inference', 'training'] },
14
- { id: 'a6000', canonical: 'A6000', name: 'NVIDIA RTX A6000', vramGb: 48, ratePerHour: 1.60, tags: ['inference', 'training'] },
15
- { id: 'a100-40gb', canonical: 'A100', name: 'NVIDIA A100 40GB', vramGb: 40, ratePerHour: 1.80, tags: ['training', 'inference'] },
16
- { id: 'a100-80gb', canonical: 'A100', name: 'NVIDIA A100 80GB', vramGb: 80, ratePerHour: 2.50, tags: ['training', 'large-model'] },
17
- { id: 'h100', canonical: 'H100', name: 'NVIDIA H100 80GB', vramGb: 80, ratePerHour: 3.50, tags: ['training', 'large-model'] },
12
+ { id: 'rtx-3080', canonical: 'RTX_3080', name: 'NVIDIA RTX 3080', vramGb: 10, ratePerHour: 0.35, tags: ['inference', 'dev'] },
13
+ { id: 'rtx-3090', canonical: 'RTX_3090', name: 'NVIDIA RTX 3090', vramGb: 24, ratePerHour: 0.60, tags: ['inference', 'dev'] },
14
+ { id: 'rtx-4090', canonical: 'RTX_4090', name: 'NVIDIA RTX 4090', vramGb: 24, ratePerHour: 1.10, tags: ['inference', 'training', 'dev'] },
15
+ { id: 'a4000', canonical: 'A4000', name: 'NVIDIA RTX A4000', vramGb: 16, ratePerHour: 0.50, tags: ['inference', 'dev'] },
16
+ { id: 'a5000', canonical: 'A5000', name: 'NVIDIA RTX A5000', vramGb: 24, ratePerHour: 0.70, tags: ['inference', 'dev'] },
17
+ { id: 'l40s', canonical: 'L40S', name: 'NVIDIA L40S', vramGb: 48, ratePerHour: 1.40, tags: ['inference', 'training'] },
18
+ { id: 'a6000', canonical: 'A6000', name: 'NVIDIA RTX A6000', vramGb: 48, ratePerHour: 1.60, tags: ['inference', 'training'] },
19
+ { id: 'a100-40gb', canonical: 'A100', name: 'NVIDIA A100 40GB', vramGb: 40, ratePerHour: 1.80, tags: ['training', 'inference'] },
20
+ { id: 'a100-80gb', canonical: 'A100_80GB', name: 'NVIDIA A100 80GB', vramGb: 80, ratePerHour: 2.50, tags: ['training', 'large-model'] },
21
+ { id: 'h100', canonical: 'H100', name: 'NVIDIA H100 80GB', vramGb: 80, ratePerHour: 3.50, tags: ['training', 'large-model'] },
18
22
  ];
19
23
 
20
24
  export function findById(id) {
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
- import { parseRunArgs, classifyFailure, inferWorkload } from '../src/commands/run.js';
2
+ import { parseRunArgs, classifyFailure } from '../src/commands/run.js';
3
3
  import { parseServeArgs } from '../src/commands/serve.js';
4
4
  import { testCommand, parseTestArgs } from '../src/commands/test-run.js';
5
5
  import { rankAlternatives, diffDescription, promptFallback } from '../src/fallback.js';
@@ -232,38 +232,9 @@ describe('parseTestArgs', () => {
232
232
  it('parses --provider secondary', () => {
233
233
  expect(parseTestArgs(['--provider', 'secondary'])).toEqual({ provider: 'secondary' });
234
234
  });
235
- });
236
-
237
- describe('inferWorkload', () => {
238
- it('returns general for empty command', () => {
239
- expect(inferWorkload([])).toBe('general');
240
- expect(inferWorkload(null)).toBe('general');
241
- });
242
-
243
- it('returns smoke_test for short print() one-liners', () => {
244
- expect(inferWorkload(['python', '-c', "print('hello')"])).toBe('smoke_test');
245
- expect(inferWorkload(['python', '-c', "print('hello from badgr')"])).toBe('smoke_test');
246
- });
247
-
248
- it('returns general for a typical script', () => {
249
- expect(inferWorkload(['python', 'script.py'])).toBe('general');
250
- expect(inferWorkload(['python', 'run.py', '--epochs', '10'])).toBe('general');
251
- });
252
-
253
- it('returns lora_finetune for LoRA/fine-tuning keywords', () => {
254
- expect(inferWorkload(['python', 'lora_train.py'])).toBe('lora_finetune');
255
- expect(inferWorkload(['python', '-c', 'import peft; finetune()'])).toBe('lora_finetune');
256
- expect(inferWorkload(['python', 'train.py', '--epochs', '3'])).toBe('lora_finetune');
257
- });
258
-
259
- it('returns image_gen for diffusion keywords', () => {
260
- expect(inferWorkload(['python', 'stable_diff.py'])).toBe('image_gen');
261
- expect(inferWorkload(['python', 'run_sdxl.py'])).toBe('image_gen');
262
- });
263
235
 
264
- it('returns inference_small for vllm/tgi', () => {
265
- expect(inferWorkload(['python', '-m', 'vllm.entrypoints.openai.api_server'])).toBe('inference_small');
266
- expect(inferWorkload(['python', 'serve_tgi.py'])).toBe('inference_small');
236
+ it('parses --no-tier-fallback', () => {
237
+ expect(parseTestArgs(['--no-tier-fallback'])).toEqual({ noTierFallback: true });
267
238
  });
268
239
  });
269
240