badgr-cli 1.0.25 → 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.25",
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) {
@@ -81,18 +81,36 @@ function renderStatusBar(chalk, { elapsedMs, ratePerHour, gpuUtil, cpuUtil, maxR
81
81
  return chalk.dim(' ' + parts.join(' • '));
82
82
  }
83
83
 
84
- // Wait for status to leave 'starting' (smoke check running on backend).
84
+ // Wait for status to leave 'starting'/'queued'/'provisioning'.
85
+ // This covers image pull time — runtime limit does NOT start until this returns.
85
86
  // Returns the final dep object with status 'running' or 'failed'.
86
87
  async function waitForRunning(config, depId, chalk) {
87
- const POLL_MS = 3000;
88
- const TIMEOUT_MS = 120_000; // 2 min max for smoke check
89
- const startMs = Date.now();
90
- let dots = 0;
88
+ const POLL_MS = 3000;
89
+ const TIMEOUT_MS = 5 * 60 * 1000; // 5 min startup grace (image pull + container init)
90
+ const startMs = Date.now();
91
+ const PHASES = [
92
+ { afterMs: 0, label: ' Starting container' },
93
+ { afterMs: 15000, label: ' Pulling image' },
94
+ { afterMs: 60000, label: ' Starting container' },
95
+ { afterMs: 180000, label: ' Running command' },
96
+ ];
97
+
98
+ let lastPhaseIdx = -1;
99
+ const STARTUP_STATES = new Set(['queued', 'provisioning', 'starting']);
91
100
 
92
- process.stdout.write(chalk.dim(' Waiting for container to start'));
93
101
  const ticker = setInterval(() => {
102
+ const elapsed = Date.now() - startMs;
103
+ // Find the latest phase whose afterMs has been passed
104
+ let phaseIdx = 0;
105
+ for (let i = 0; i < PHASES.length; i++) {
106
+ if (elapsed >= PHASES[i].afterMs) phaseIdx = i;
107
+ }
108
+ if (phaseIdx !== lastPhaseIdx) {
109
+ process.stdout.write('\r\x1b[2K');
110
+ process.stdout.write(chalk.dim(PHASES[phaseIdx].label));
111
+ lastPhaseIdx = phaseIdx;
112
+ }
94
113
  process.stdout.write('.');
95
- dots++;
96
114
  }, 1000);
97
115
 
98
116
  try {
@@ -102,23 +120,22 @@ async function waitForRunning(config, depId, chalk) {
102
120
  apiKey: config.apiKey,
103
121
  baseUrl: config.baseUrl,
104
122
  });
105
- if (dep.status !== 'starting') {
106
- clearInterval(ticker);
123
+ if (!STARTUP_STATES.has(dep.status)) {
107
124
  process.stdout.write('\n');
108
125
  return dep;
109
126
  }
110
127
  }
111
128
  } finally {
112
129
  clearInterval(ticker);
113
- if (dots > 0) process.stdout.write('\n');
130
+ process.stdout.write('\n');
114
131
  }
115
132
 
116
- // Timed out waiting — return whatever we have
133
+ // Timed out — return last known state
117
134
  return await callApi(`/deployments/${depId}`, { apiKey: config.apiKey, baseUrl: config.baseUrl });
118
135
  }
119
136
 
120
137
  async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost = null, ratePerHour = 0, onTeardown }) {
121
- const TERMINAL = new Set(['stopped', 'failed', 'completed']);
138
+ const TERMINAL = new Set(['stopped', 'failed', 'completed', 'succeeded']);
122
139
  const POLL_MS = 4000;
123
140
  let seenContent = new Set(); // track by content, not index, to avoid reprinting stale lines
124
141
  let lastStatus = '';
@@ -268,63 +285,6 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
268
285
  }
269
286
  }
270
287
 
271
- /**
272
- * Infer a workload profile name from the command the user wants to run.
273
- * The backend uses this to enforce a VRAM floor when picking a GPU.
274
- *
275
- * Note: \b word boundaries are intentionally avoided for keyword checks
276
- * because keywords commonly appear inside filenames joined by underscores
277
- * (e.g. lora_train.py, run_sdxl.py) where `_` is a word character.
278
- */
279
- export function inferWorkload(command) {
280
- if (!command || command.length === 0) return 'general';
281
-
282
- const cmd = command.join(' ').toLowerCase();
283
-
284
- // Trivial one-liner / smoke test
285
- if (/print\s*\(|['"]hello/.test(cmd) && cmd.length < 80) return 'smoke_test';
286
-
287
- // LoRA / QLoRA / PEFT fine-tuning
288
- if (/lora|qlora|finetune|fine[_-]tun|peft/.test(cmd)) return 'lora_finetune';
289
-
290
- // Diffusion / image generation
291
- if (/diffusion|stable.?diff|sdxl|sd.?xl|comfyui|a1111|invoke|kohya/.test(cmd)) return 'image_gen';
292
-
293
- // vLLM / TGI / inference server
294
- if (/vllm|[^a-z]tgi[^a-z]|^tgi\b|tgi$|text.generation.inference/.test(cmd)) return 'inference_small';
295
-
296
- // Explicit training script
297
- if (/\btrain\.py\b/.test(cmd)) return 'lora_finetune';
298
-
299
- return 'general';
300
- }
301
-
302
- const WORKLOAD_LABELS = {
303
- smoke_test: 'smoke test',
304
- general: 'GPU job',
305
- lora_finetune: 'fine-tuning (40GB+ VRAM)',
306
- image_gen: 'image generation',
307
- inference_small: 'inference (7B–8B model)',
308
- inference_medium: 'inference (30B–34B model)',
309
- inference_large: 'inference (70B+ model)',
310
- };
311
-
312
- // Ask the backend for the best available GPU for a given workload.
313
- // Routes by: workload → min VRAM → provider tier → cheapest match.
314
- // Returns { gpu, region, price, workload, workload_desc } or null when nothing is available.
315
- async function findAutoGpu(config, chalk, tier = '1', workload = 'general') {
316
- try {
317
- const params = new URLSearchParams({ max_price: '10', tier, workload });
318
- return await callApi(`/capacity/auto?${params}`, {
319
- apiKey: config.apiKey,
320
- baseUrl: config.baseUrl,
321
- });
322
- } catch (err) {
323
- if (err.errorData?.code === 'NO_CAPACITY_MATCH') return null;
324
- throw err;
325
- }
326
- }
327
-
328
288
  function askConfirm(prompt) {
329
289
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
330
290
  return new Promise(resolve => rl.question(prompt, ans => { rl.close(); resolve(ans.trim()); }));
@@ -342,7 +302,11 @@ export async function runCommand(config, args, chalk) {
342
302
  requireApiKey(config);
343
303
 
344
304
  const command = positional.length > 0 ? positional : undefined;
345
- const image = flags.image || (command ? 'python:3.11-slim' : undefined);
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';
309
+ const image = flags.image || (command ? inferredImage : undefined);
346
310
  const detach = flags.detach || false;
347
311
  const fallbackMode = flags.noFallback ? 'none' : (flags.fallback || 'closest');
348
312
  const maxRuntimeMs = flags.maxRuntime ? flags.maxRuntime * 60 * 1000 : null;
@@ -354,54 +318,14 @@ export async function runCommand(config, args, chalk) {
354
318
  : (flags.tier || '1');
355
319
 
356
320
  // ── Auto GPU selection (no --gpu specified) ────────────────────────────────
357
- let gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : null;
358
- let autoRegion = null;
321
+ let gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : 'auto';
359
322
 
360
- // Infer workload from the command so the backend can apply the correct VRAM floor.
361
- const workload = command ? inferWorkload(command) : 'general';
362
- const workloadLabel = WORKLOAD_LABELS[workload] || 'GPU job';
363
-
364
- if (!gpu) {
365
- console.log(chalk.bold(`\n⚔ Running ${workloadLabel}\n`));
323
+ if (gpu === 'auto') {
324
+ console.log(chalk.bold('\n⚔ Running GPU job\n'));
366
325
  if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
367
326
  if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
368
327
  if (effectiveTier === '2') console.log(` ${chalk.dim('(tier 2 — marketplace routing)')}`)
369
328
  console.log();
370
- process.stdout.write(chalk.dim(' Finding best GPU...'));
371
-
372
- let best;
373
- try {
374
- best = await findAutoGpu(config, chalk, effectiveTier, workload);
375
- } catch (err) {
376
- process.stdout.write('\n');
377
- console.error(chalk.red(`\n āœ— Could not find GPU capacity: ${err.message}\n`));
378
- process.exit(1);
379
- }
380
-
381
- process.stdout.write('\n');
382
-
383
- if (!best) {
384
- console.error(chalk.red('\n āœ— No GPU capacity available right now.\n'));
385
- console.error(chalk.dim(' Run `badgr capacity` for details, or try again in a few minutes.'));
386
- process.exit(1);
387
- }
388
-
389
- const vramNote = best.min_vram_gb ? chalk.dim(` (${best.min_vram_gb}GB VRAM)`) : '';
390
- console.log(`\n ${chalk.bold('Selected:')} ${chalk.cyan(best.gpu)} in ${best.region} — ${chalk.green('$' + best.price.toFixed(2) + '/hr')}${vramNote}`);
391
- console.log();
392
-
393
- if (effectiveTier === '2' && process.stdin.isTTY) {
394
- // Budget mode: confirm because the user is being routed to a less reliable provider.
395
- const answer = await askConfirm(` Press ${chalk.bold('Enter')} to run on tier 2 (marketplace), or ${chalk.bold('q')} to cancel: `);
396
- if (answer.toLowerCase() === 'q') {
397
- console.log(chalk.dim('\n Cancelled.\n'));
398
- process.exit(0);
399
- }
400
- }
401
-
402
- gpu = best.gpu;
403
- autoRegion = best.region;
404
- console.log();
405
329
  } else {
406
330
  // Specific GPU requested — show header
407
331
  console.log(chalk.bold('\n⚔ Running GPU job\n'));
@@ -419,15 +343,13 @@ export async function runCommand(config, args, chalk) {
419
343
  }
420
344
  }
421
345
 
422
- console.log(chalk.dim(' Finding best available GPU capacity...'));
346
+ console.log(chalk.dim(' Finding reliable capacity...'));
423
347
  if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
424
348
  console.log(chalk.dim(` API: ${config.baseUrl}`));
425
349
  }
426
350
 
427
- function buildBody(gpuOverride, regionOverride) {
428
- const effectiveRegion = regionOverride
429
- ?? autoRegion
430
- ?? (flags.region ? flags.region.toUpperCase() : undefined);
351
+ function buildBody(gpuOverride) {
352
+ const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
431
353
  return {
432
354
  command,
433
355
  image,
@@ -534,6 +456,7 @@ export async function runCommand(config, args, chalk) {
534
456
  console.log();
535
457
  console.log(chalk.bold(` Job ID: ${chalk.cyan(dep.deployment_id)}`));
536
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}`);
537
460
  if (dep.tier) console.log(` ${chalk.bold('Tier:')} ${dep.tier}`);
538
461
  if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
539
462
  console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
@@ -544,8 +467,10 @@ export async function runCommand(config, args, chalk) {
544
467
  return;
545
468
  }
546
469
 
547
- // If the backend is still running the smoke check, wait for it to finish.
548
- if (dep.status === 'starting') {
470
+ // Wait through startup phases (queued → provisioning → starting → running).
471
+ // The max-runtime clock does NOT start until this returns — image pull time is free.
472
+ const STARTUP_STATES = new Set(['queued', 'provisioning', 'starting']);
473
+ if (STARTUP_STATES.has(dep.status)) {
549
474
  console.log();
550
475
  dep = await waitForRunning(config, dep.deployment_id, chalk);
551
476
  }
@@ -559,7 +484,7 @@ export async function runCommand(config, args, chalk) {
559
484
  }
560
485
 
561
486
  const ratePerHour = dep.cost_per_hour || 0;
562
- console.log(chalk.dim('\n ── Live status (Ctrl+C to stop) ─────────────────────────────────\n'));
487
+ console.log(chalk.dim('\n ── Running command (Ctrl+C to stop) ────────────────────────────\n'));
563
488
 
564
489
  async function teardown(reason) {
565
490
  const labels = {
@@ -621,7 +546,12 @@ export async function runCommand(config, args, chalk) {
621
546
  }
622
547
  console.log();
623
548
  process.exit(exitCode ?? 1);
624
- } else if (finalStatus === 'completed' && (exitCode === 0 || exitCode === null)) {
625
- console.log(chalk.green(`\n āœ“ Complete\n`));
549
+ } else if ((finalStatus === 'completed' || finalStatus === 'succeeded') && (exitCode === 0 || exitCode === null)) {
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();
626
556
  }
627
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,11 +2,13 @@ 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
- const TEST_IMAGE = 'python:3.11-slim';
9
+ // Use alpine (7MB) instead of slim (50MB) — dramatically faster image pull for smoke tests.
10
+ // Falls back gracefully: alpine has python3 and supports the test command identically.
11
+ const TEST_IMAGE = 'python:3.11-alpine';
10
12
  const EXPECTED_OUTPUT = 'hello from badgr';
11
13
 
12
14
  // --provider flag resolves to a backend tier value.
@@ -18,6 +20,7 @@ export function parseTestArgs(args) {
18
20
  let i = 0;
19
21
  while (i < args.length) {
20
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; }
21
24
  i++;
22
25
  }
23
26
  return flags;
@@ -44,20 +47,36 @@ async function pollStatus(config, depId, targetStatuses, timeoutMs) {
44
47
  return null;
45
48
  }
46
49
 
47
- async function pollLogs(config, depId, expected, timeoutMs) {
50
+ async function pollOutputOrDone(config, depId, expected, timeoutMs) {
48
51
  const deadline = Date.now() + timeoutMs;
49
52
  while (Date.now() < deadline) {
50
- await new Promise(r => setTimeout(r, 4000));
53
+ await new Promise(r => setTimeout(r, 3000));
51
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
+ }
52
71
  const data = await callApi(`/deployments/${depId}/logs`, {
53
72
  apiKey: config.apiKey,
54
73
  baseUrl: config.baseUrl,
55
74
  });
56
75
  const lines = data?.logs ?? [];
57
- if (lines.some(l => l.includes(expected))) return true;
76
+ if (lines.some(l => l.includes(expected))) return { done: true, ok: true, exitCode: 0 };
58
77
  } catch { /* retry */ }
59
78
  }
60
- return false;
79
+ return { done: false, ok: false, exitCode: null };
61
80
  }
62
81
 
63
82
  export async function testCommand(config, args, chalk) {
@@ -106,7 +125,7 @@ export async function testCommand(config, args, chalk) {
106
125
  const baseBody = {
107
126
  command: TEST_COMMAND,
108
127
  image: TEST_IMAGE,
109
- gpu: 'RTX_3080',
128
+ gpu: 'auto',
110
129
  max_price_per_hour: TEST_MAX_PRICE,
111
130
  };
112
131
  try {
@@ -117,7 +136,7 @@ export async function testCommand(config, args, chalk) {
117
136
  body: { ...baseBody, tier },
118
137
  });
119
138
  } catch (err) {
120
- if (err.errorData?.code === 'NO_CAPACITY_MATCH' && tier === '1') {
139
+ if (err.errorData?.code === 'NO_CAPACITY_MATCH' && tier === '1' && !flags.noTierFallback) {
121
140
  process.stdout.write('\n');
122
141
  process.stdout.write(chalk.dim(' No tier 1 capacity — trying tier 2 marketplace routing...'));
123
142
  try {
@@ -134,6 +153,12 @@ export async function testCommand(config, args, chalk) {
134
153
  console.error(chalk.red(' Test failed — no GPU capacity available on any provider.\n'));
135
154
  process.exit(1);
136
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);
137
162
  } else {
138
163
  process.stdout.write('\n');
139
164
  step(chalk, false, 'Provisioned', err.message);
@@ -166,10 +191,13 @@ export async function testCommand(config, args, chalk) {
166
191
 
167
192
  // ── 3. Command output ────────────────────────────────────────────────────
168
193
  process.stdout.write(chalk.dim(' Checking command output...'));
169
- const gotOutput = await pollLogs(config, depId, EXPECTED_OUTPUT, 90_000);
194
+ const outputResult = await pollOutputOrDone(config, depId, EXPECTED_OUTPUT, 90_000);
170
195
  process.stdout.write('\n');
196
+ const gotOutput = outputResult.ok;
171
197
  if (gotOutput) {
172
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}`);
173
201
  } else {
174
202
  step(chalk, false, 'Command printed output', 'not found in logs (logs may be buffered)');
175
203
  }
@@ -190,18 +218,22 @@ export async function testCommand(config, args, chalk) {
190
218
  action: 'badgr test',
191
219
  deploymentId: depId,
192
220
  gpu: dep.gpu_type,
193
- status: 'test_complete',
221
+ status: gotOutput ? 'test_passed' : 'test_failed',
194
222
  createdAt: new Date().toISOString(),
195
223
  });
196
224
  step(chalk, true, 'Receipt created', rcptId);
197
225
 
198
226
  // ── Summary ──────────────────────────────────────────────────────────────
199
227
  console.log();
200
- const passed = stopped;
228
+ const passed = stopped && gotOutput;
201
229
  if (passed) {
202
230
  console.log(chalk.green(chalk.bold(' āœ“ Test passed\n')));
203
231
  } else {
204
- console.log(chalk.red(chalk.bold(' āœ— Test failed\n')));
232
+ if (!gotOutput) {
233
+ console.error(chalk.red(' Test failed — expected output not found in logs\n'));
234
+ } else {
235
+ console.error(chalk.red(' Test failed — could not stop billing\n'));
236
+ }
205
237
  process.exit(1);
206
238
  }
207
239
  }
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