badgr-cli 1.0.14 → 1.0.15

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.14",
3
+ "version": "1.0.15",
4
4
  "description": "Badgr, run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -309,15 +309,24 @@ export async function runCommand(config, args, chalk) {
309
309
  const d2 = err2.errorData;
310
310
  if (d2?.code === 'PROVISIONING_FAILED' || d2?.code === 'PROVIDER_ADAPTER_ERROR') {
311
311
  console.error(chalk.red(`\n ✗ Badgr found ${chosen.gpu} capacity but could not start the machine. Please try again.\n`));
312
+ if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
313
+ if (d2?.debug_error) console.error(chalk.dim(` Provider detail: ${d2.debug_error}`));
314
+ }
312
315
  } else {
313
316
  console.error(chalk.red(`\n ✗ Job failed to start on ${chosen.gpu}: ${err2.message}\n`));
314
317
  }
315
- console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run for a full trace.\n`));
318
+ if (!(process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true')) {
319
+ console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.\n`));
320
+ }
316
321
  process.exit(1);
317
322
  }
318
323
  } else if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
319
324
  console.error(chalk.red(`\n ✗ Badgr found ${gpu} capacity but could not start the machine. Please try again.\n`));
320
- console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run for a full trace.\n`));
325
+ if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
326
+ if (d?.debug_error) console.error(chalk.dim(` Provider detail: ${d.debug_error}`));
327
+ } else {
328
+ console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.\n`));
329
+ }
321
330
  process.exit(1);
322
331
  } else {
323
332
  console.error(chalk.red(`\n ✗ Could not start job: ${err.message}\n`));
@@ -1,37 +1,31 @@
1
1
  import { parseSpec, validateSpec, specLines } from '../spec.js';
2
- import { getRoutePlan } from '../router.js';
3
2
  import { requireApiKey } from '../config.js';
4
3
  import { generateDeploymentId, generateReceiptId, addDeployment, addReceipt } from '../store.js';
5
- import { createDeployment } from '../api.js';
4
+ import { createDeployment, callApi } from '../api.js';
6
5
 
7
- const OVERHEAD_PCT = 0.25;
6
+ function printLivePlan(suggestions, gpu, chalk) {
7
+ const { matches = [], alternatives = [] } = suggestions;
8
8
 
9
- function printRoutePlan(plan, chalk) {
10
- const { lane1, lane2, cheapestRate, costWithOverhead, canonical } = plan;
11
-
12
- console.log(chalk.bold(' Lane 1 — Own GPU Hosts'));
9
+ console.log(chalk.bold(' Live availability'));
13
10
  console.log(` ${'─'.repeat(40)}`);
14
- console.log(chalk.dim(` ${lane1.description}`));
11
+ if (matches.length === 0) {
12
+ console.log(chalk.yellow(` No ${gpu} capacity found right now`));
13
+ } else {
14
+ const cheapest = matches[0];
15
+ console.log(` ${chalk.cyan(gpu)} available — from ${chalk.green('$' + cheapest.price.toFixed(2) + '/hr')}`);
16
+ console.log(chalk.dim(` ${matches.length} offer(s) found`));
17
+ }
15
18
  console.log();
16
19
 
17
- console.log(chalk.bold(' Lane 2 Overflow Providers (cheapest-first)'));
18
- console.log(` ${'─'.repeat(40)}`);
19
- if (lane2.length === 0) {
20
- console.log(chalk.yellow(` No pricing data for ${canonical}`));
21
- } else {
22
- lane2.forEach((p, i) => {
23
- const arrow = i === 0 ? chalk.green(' ← primary') : '';
24
- console.log(
25
- ` ${String(i + 1)}. ${p.provider.padEnd(12)} ${canonical.padEnd(10)}` +
26
- ` $${p.ratePerHour.toFixed(2)}/hr` +
27
- ` reliability: ${Math.round(p.reliability * 100)}%${arrow}`
28
- );
20
+ if (alternatives.length > 0) {
21
+ console.log(chalk.bold(' Alternatives if unavailable'));
22
+ console.log(` ${'─'.repeat(40)}`);
23
+ alternatives.slice(0, 3).forEach((a, i) => {
24
+ const diff = a.diff_desc ? chalk.dim(` — ${a.diff_desc}`) : '';
25
+ console.log(` ${i + 1}. ${chalk.cyan(a.gpu)} in ${a.region} $${a.price.toFixed(2)}/hr${diff}`);
29
26
  });
30
27
  console.log();
31
- console.log(` Estimated range: $${cheapestRate.toFixed(2)}–$${lane2[lane2.length - 1].ratePerHour.toFixed(2)}/hr`);
32
- console.log(chalk.dim(` Estimated Badgr price: ~$${costWithOverhead.toFixed(2)}/hr`));
33
28
  }
34
- console.log();
35
29
  console.log(chalk.dim(' Remove --dry-run to provision.'));
36
30
  console.log();
37
31
  }
@@ -45,15 +39,27 @@ export async function upCommand(config, args, chalk) {
45
39
  return;
46
40
  }
47
41
 
48
- // ── Dry-run: show route plan and exit ─────────────────────────────────────
42
+ // ── Dry-run: show live route plan from backend ───────────────────────────
49
43
  if (spec.dryRun) {
50
44
  console.log(chalk.bold('\n🔍 Dry Run — Route Plan\n'));
51
45
  console.log(chalk.bold(' Spec'));
52
46
  console.log(` ${'─'.repeat(40)}`);
53
47
  specLines(spec).forEach(l => console.log(` ${l}`));
54
48
  console.log();
55
- const plan = getRoutePlan(spec.gpu, spec.count);
56
- printRoutePlan(plan, chalk);
49
+
50
+ requireApiKey(config);
51
+ try {
52
+ const params = new URLSearchParams({ gpu: spec.gpu, max_price: String(spec.maxPrice ?? 10) });
53
+ if (spec.region) params.set('region', spec.region);
54
+ const suggestions = await callApi(`/capacity/suggestions?${params}`, {
55
+ apiKey: config.apiKey,
56
+ baseUrl: config.baseUrl,
57
+ });
58
+ printLivePlan(suggestions, spec.gpu, chalk);
59
+ } catch (err) {
60
+ console.log(chalk.yellow(` Could not fetch live availability: ${err.message}`));
61
+ console.log(chalk.dim(' Tip: check `badgr capacity` for live data.\n'));
62
+ }
57
63
  return;
58
64
  }
59
65
 
package/src/fallback.js CHANGED
@@ -1,73 +1,43 @@
1
1
  import readline from 'readline';
2
2
 
3
- // Static GPU metadata for local ranking. Routing still happens server-side.
4
- const GPU_META = {
5
- RTX_3080: { vramGb: 10, family: 'consumer', tags: ['inference', 'dev'], desc: 'Dev, light inference' },
6
- RTX_4080: { vramGb: 16, family: 'consumer', tags: ['inference', 'dev'], desc: 'Inference and dev workloads' },
7
- RTX_4090: { vramGb: 24, family: 'consumer', tags: ['inference', 'training', 'dev'], desc: 'Inference, training, dev' },
8
- L40S: { vramGb: 48, family: 'datacenter', tags: ['inference', 'training'], desc: 'Inference, vLLM, batch jobs' },
9
- A6000: { vramGb: 48, family: 'datacenter', tags: ['training', 'inference'], desc: 'Training, large models, inference' },
10
- A100: { vramGb: 40, family: 'datacenter', tags: ['training', 'inference'], desc: 'Large-scale training and inference' },
11
- H100: { vramGb: 80, family: 'datacenter', tags: ['training', 'large-model'], desc: 'Large model training, best throughput' },
3
+ // GPU descriptions for display only no scoring logic lives here.
4
+ // Ranking is computed server-side and returned as `rank` on each alternative.
5
+ const GPU_DISPLAY = {
6
+ RTX_3080: { desc: 'Dev, light inference' },
7
+ RTX_4080: { desc: 'Inference and dev workloads' },
8
+ RTX_4090: { desc: 'Inference, training, dev' },
9
+ L40S: { desc: 'Inference, vLLM, batch jobs' },
10
+ A6000: { desc: 'Training, large models, inference' },
11
+ A100: { desc: 'Large-scale training and inference' },
12
+ H100: { desc: 'Large model training, best throughput' },
12
13
  };
13
14
 
14
- function altScore(alt, reqMeta) {
15
- const meta = GPU_META[alt.gpu] ?? { vramGb: 0, family: 'unknown', tags: [] };
16
- let s = 0;
17
-
18
- // 1. GPU class/family similarity
19
- if (meta.family === reqMeta.family) s += 200;
20
-
21
- // 2. VRAM — prefer same or more, penalize less
22
- const vramDiff = meta.vramGb - reqMeta.vramGb;
23
- if (vramDiff >= 0) {
24
- s += 80 - Math.min(vramDiff, 40) * 0.5;
25
- } else {
26
- s += 80 + vramDiff * 4;
27
- }
28
-
29
- // 3. Workload tag overlap
30
- s += reqMeta.tags.filter(t => meta.tags.includes(t)).length * 20;
31
-
32
- // 4. Availability confidence — not in alternatives payload, treated equal
33
-
34
- // 5. Price — lower wins
35
- s -= alt.price * 10;
36
-
37
- return s;
38
- }
39
-
40
15
  /**
41
- * Rank a list of { gpu, region, price } alternatives.
42
- * mode 'closest' → family / VRAM / tag / price (default)
43
- * mode 'cheapest' price ascending only
16
+ * Sort alternatives for display.
17
+ *
18
+ * - mode 'closest' use backend `rank` field (1 = best match); fall back to price.
19
+ * - mode 'cheapest' → sort by price ascending only.
20
+ *
21
+ * The scoring algorithm has been removed from the CLI; the backend now
22
+ * computes and attaches `rank` and `diff_desc` to every alternative.
44
23
  */
45
24
  export function rankAlternatives(requestedGpu, alternatives, mode = 'closest') {
46
25
  if (!alternatives || alternatives.length === 0) return [];
47
26
  if (mode === 'cheapest') return [...alternatives].sort((a, b) => a.price - b.price);
48
-
49
- const reqMeta = GPU_META[requestedGpu] ?? { vramGb: 0, family: 'unknown', tags: [] };
50
- return [...alternatives].sort((a, b) => altScore(b, reqMeta) - altScore(a, reqMeta));
27
+ // 'closest': honour backend rank when present
28
+ if (alternatives.some(a => a.rank != null)) {
29
+ return [...alternatives].sort((a, b) => (a.rank ?? 999) - (b.rank ?? 999));
30
+ }
31
+ // Older backend without rank — fall back to price
32
+ return [...alternatives].sort((a, b) => a.price - b.price);
51
33
  }
52
34
 
53
35
  /**
54
- * One-line human-readable diff between requested and chosen GPU.
55
- * e.g. "less VRAM (24GB vs 40GB), different GPU class than A100"
36
+ * One-line diff for display. Uses `diff_desc` from backend when present,
37
+ * otherwise omits the diff rather than duplicating the scoring logic.
56
38
  */
57
- export function diffDescription(requestedGpu, altGpu) {
58
- const req = GPU_META[requestedGpu];
59
- const alt = GPU_META[altGpu];
60
- if (!req || !alt) return '';
61
-
62
- const parts = [];
63
- const vramDiff = alt.vramGb - req.vramGb;
64
- if (vramDiff < -4) parts.push(`less VRAM (${alt.vramGb}GB vs ${req.vramGb}GB)`);
65
- else if (vramDiff > 4) parts.push(`more VRAM (${alt.vramGb}GB vs ${req.vramGb}GB)`);
66
-
67
- if (alt.family !== req.family) parts.push('different GPU class');
68
-
69
- if (parts.length === 0) return `similar specs to ${requestedGpu}`;
70
- return parts.join(', ') + ` than ${requestedGpu}`;
39
+ export function diffDescription(requestedGpu, altGpu, alt = {}) {
40
+ return alt.diff_desc || '';
71
41
  }
72
42
 
73
43
  function ask(prompt) {
@@ -82,15 +52,16 @@ function ask(prompt) {
82
52
  export async function promptFallback(requestedGpu, ranked, chalk) {
83
53
  if (ranked.length === 0) return null;
84
54
 
85
- const top = ranked[0];
86
- const others = ranked.slice(1, 4); // cap at 3 extras for readability
87
- const topMeta = GPU_META[top.gpu];
55
+ const top = ranked[0];
56
+ const others = ranked.slice(1, 4);
57
+ const topDisp = GPU_DISPLAY[top.gpu];
88
58
 
89
59
  console.log(chalk.yellow(`\n ${requestedGpu} isn't available right now.\n`));
90
60
  console.log(chalk.bold(' Closest match:'));
91
61
  console.log(` ${chalk.cyan(top.gpu)} in ${top.region}`);
92
62
  console.log(` ${chalk.green('$' + top.price.toFixed(2) + '/hr')} estimated price`);
93
- if (topMeta?.desc) console.log(` ${topMeta.desc}`);
63
+ if (top.diff_desc) console.log(` ${chalk.dim(top.diff_desc)}`);
64
+ else if (topDisp?.desc) console.log(` ${topDisp.desc}`);
94
65
  console.log(chalk.dim(' (availability estimated from market data — not pre-verified)'));
95
66
  console.log();
96
67
 
@@ -103,8 +74,8 @@ export async function promptFallback(requestedGpu, ranked, chalk) {
103
74
  if (others.length > 0) {
104
75
  console.log(' or type:');
105
76
  for (const [i, alt] of others.entries()) {
106
- const meta = GPU_META[alt.gpu];
107
- const hint = meta ? meta.desc.split(',')[0].toLowerCase() : '';
77
+ const disp = GPU_DISPLAY[alt.gpu];
78
+ const hint = disp ? disp.desc.split(',')[0].toLowerCase() : '';
108
79
  console.log(` ${chalk.bold(String(i + 1))} = ${alt.gpu}${hint ? ', ' + hint : ''}`);
109
80
  }
110
81
  }
package/src/router.js CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
- * GPU catalog and routing preview.
2
+ * GPU catalog static specs only (name, VRAM, tags, indicative rate).
3
3
  *
4
- * Provider pricing mirrors the order in overflow_providers.py:
5
- * Vast.ai RunPod TensorDock SaladCloud.
6
- * Actual routing happens server-side; this is the dry-run preview.
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).
7
8
  */
8
9
 
9
10
  export const GPU_CATALOG = [
@@ -11,46 +12,11 @@ export const GPU_CATALOG = [
11
12
  { id: 'rtx-4090', canonical: 'RTX_4090', name: 'NVIDIA RTX 4090', vramGb: 24, ratePerHour: 1.10, tags: ['inference', 'training', 'dev'] },
12
13
  { id: 'l40s', canonical: 'L40S', name: 'NVIDIA L40S', vramGb: 48, ratePerHour: 1.40, tags: ['inference', 'training'] },
13
14
  { id: 'a6000', canonical: 'A6000', name: 'NVIDIA RTX A6000', vramGb: 48, ratePerHour: 1.60, tags: ['inference', 'training'] },
14
- { id: 'a100-40gb', canonical: 'A100', name: 'NVIDIA A100 40GB', vramGb: 40, ratePerHour: 1.80, tags: ['training', 'inference'] },
15
- { id: 'a100-80gb', canonical: 'A100', name: 'NVIDIA A100 80GB', vramGb: 80, ratePerHour: 2.50, tags: ['training', 'large-model'] },
16
- { id: 'h100', canonical: 'H100', name: 'NVIDIA H100 80GB', vramGb: 80, ratePerHour: 3.50, tags: ['training', 'large-model'] },
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'] },
17
18
  ];
18
19
 
19
- // Provider-level pricing per canonical GPU type (ordered cheapest-first per provider).
20
- // Mirrors overflow_providers.py search ordering: Vast → RunPod → TensorDock → Salad.
21
- export const PROVIDER_CATALOG = {
22
- RTX_3080: [
23
- { provider: 'vastai', ratePerHour: 0.28, reliability: 0.93 },
24
- { provider: 'runpod', ratePerHour: 0.35, reliability: 0.96 },
25
- { provider: 'tensordock', ratePerHour: 0.40, reliability: 0.90 },
26
- ],
27
- RTX_4090: [
28
- { provider: 'vastai', ratePerHour: 0.65, reliability: 0.94 },
29
- { provider: 'runpod', ratePerHour: 0.72, reliability: 0.97 },
30
- { provider: 'tensordock', ratePerHour: 0.81, reliability: 0.91 },
31
- { provider: 'salad', ratePerHour: 0.89, reliability: 0.89 },
32
- ],
33
- L40S: [
34
- { provider: 'vastai', ratePerHour: 1.10, reliability: 0.94 },
35
- { provider: 'runpod', ratePerHour: 1.25, reliability: 0.97 },
36
- { provider: 'salad', ratePerHour: 1.40, reliability: 0.88 },
37
- ],
38
- A6000: [
39
- { provider: 'vastai', ratePerHour: 1.05, reliability: 0.93 },
40
- { provider: 'runpod', ratePerHour: 1.20, reliability: 0.97 },
41
- { provider: 'salad', ratePerHour: 1.35, reliability: 0.88 },
42
- ],
43
- A100: [
44
- { provider: 'runpod', ratePerHour: 1.20, reliability: 0.98 },
45
- { provider: 'vastai', ratePerHour: 1.35, reliability: 0.95 },
46
- { provider: 'tensordock', ratePerHour: 1.50, reliability: 0.92 },
47
- ],
48
- H100: [
49
- { provider: 'runpod', ratePerHour: 2.80, reliability: 0.99 },
50
- { provider: 'vastai', ratePerHour: 3.10, reliability: 0.96 },
51
- ],
52
- };
53
-
54
20
  export function findById(id) {
55
21
  return GPU_CATALOG.find(g => g.id === id) ?? null;
56
22
  }
@@ -72,33 +38,6 @@ export function listAll() {
72
38
  return [...GPU_CATALOG].sort((a, b) => a.ratePerHour - b.ratePerHour);
73
39
  }
74
40
 
75
- /**
76
- * Build the routing preview shown in --dry-run output.
77
- * Mirrors the lane 1→2→3 logic in overflow_dispatch.py.
78
- */
79
- export function getRoutePlan(canonical, count = 1) {
80
- const providers = PROVIDER_CATALOG[canonical] ?? [];
81
- const sorted = [...providers].sort((a, b) => a.ratePerHour - b.ratePerHour);
82
-
83
- const cheapest = sorted[0];
84
- const overhead = 0.25; // ~25% overhead: startup risk + failure buffer + badgr margin
85
- const costWithOverhead = cheapest ? cheapest.ratePerHour * (1 + overhead) * count : null;
86
-
87
- return {
88
- canonical,
89
- gpu: findByCanonical(canonical),
90
- lane1: { label: 'Own GPU hosts', description: 'checked at runtime against live worker pool' },
91
- lane2: sorted.map((p, i) => ({
92
- rank: i + 1,
93
- provider: p.provider,
94
- ratePerHour: p.ratePerHour * count,
95
- reliability: p.reliability,
96
- })),
97
- cheapestRate: cheapest ? cheapest.ratePerHour * count : null,
98
- costWithOverhead,
99
- };
100
- }
101
-
102
41
  export function estimateCost(ratePerHour, durationMinutes) {
103
42
  return (ratePerHour / 60) * durationMinutes;
104
43
  }
@@ -96,10 +96,10 @@ describe('classifyFailure', () => {
96
96
 
97
97
  describe('rankAlternatives', () => {
98
98
  const pool = [
99
- { gpu: 'RTX_4090', region: 'US', price: 0.72 },
100
- { gpu: 'L40S', region: 'US', price: 1.25 },
101
- { gpu: 'H100', region: 'EU', price: 2.80 },
102
- { gpu: 'A6000', region: 'US', price: 1.20 },
99
+ { gpu: 'RTX_4090', region: 'US', price: 0.72, rank: 3 },
100
+ { gpu: 'L40S', region: 'US', price: 1.25, rank: 2 },
101
+ { gpu: 'H100', region: 'EU', price: 2.80, rank: 1 },
102
+ { gpu: 'A6000', region: 'US', price: 1.20 }, // no rank field
103
103
  ];
104
104
 
105
105
  it('cheapest mode sorts by price ascending', () => {
@@ -108,13 +108,25 @@ describe('rankAlternatives', () => {
108
108
  expect(ranked[ranked.length - 1].price).toBe(2.80);
109
109
  });
110
110
 
111
- it('closest mode prefers same GPU family over cheaper consumer GPU', () => {
112
- const ranked = rankAlternatives('A100', pool, 'closest');
113
- const topGpu = ranked[0].gpu;
114
- // H100, A6000, and L40S are all datacenter — any of them should beat RTX_4090
115
- expect(['H100', 'A6000', 'L40S']).toContain(topGpu);
116
- const rtxPos = ranked.findIndex(a => a.gpu === 'RTX_4090');
117
- expect(rtxPos).toBeGreaterThan(0);
111
+ it('closest mode honours backend rank field when present', () => {
112
+ const poolWithRanks = [
113
+ { gpu: 'RTX_4090', region: 'US', price: 0.72, rank: 3 },
114
+ { gpu: 'L40S', region: 'US', price: 1.25, rank: 2 },
115
+ { gpu: 'H100', region: 'EU', price: 2.80, rank: 1 },
116
+ ];
117
+ const ranked = rankAlternatives('A100', poolWithRanks, 'closest');
118
+ expect(ranked[0].gpu).toBe('H100'); // rank 1 → top
119
+ expect(ranked[1].gpu).toBe('L40S'); // rank 2
120
+ expect(ranked[2].gpu).toBe('RTX_4090'); // rank 3
121
+ });
122
+
123
+ it('closest mode falls back to price sort when no rank field', () => {
124
+ const noRankPool = [
125
+ { gpu: 'RTX_4090', region: 'US', price: 0.72 },
126
+ { gpu: 'L40S', region: 'US', price: 1.25 },
127
+ ];
128
+ const ranked = rankAlternatives('A100', noRankPool, 'closest');
129
+ expect(ranked[0].price).toBe(0.72);
118
130
  });
119
131
 
120
132
  it('returns empty array for empty pool', () => {
@@ -127,27 +139,17 @@ describe('rankAlternatives', () => {
127
139
  });
128
140
 
129
141
  describe('diffDescription', () => {
130
- it('reports more VRAM when alt has significantly more', () => {
131
- const desc = diffDescription('RTX_4090', 'L40S'); // 24GB vs 48GB
132
- expect(desc).toContain('more VRAM');
133
- });
134
-
135
- it('reports less VRAM when alt has significantly less', () => {
136
- const desc = diffDescription('A100', 'RTX_4090'); // 40GB vs 24GB
137
- expect(desc).toContain('less VRAM');
138
- });
139
-
140
- it('reports different GPU class when family differs', () => {
141
- const desc = diffDescription('A100', 'RTX_4090');
142
- expect(desc).toContain('different GPU class');
142
+ it('uses diff_desc from alt object when backend provides it', () => {
143
+ const alt = { diff_desc: 'less VRAM (24GB vs 40GB) than A100' };
144
+ expect(diffDescription('A100', 'RTX_4090', alt)).toBe(alt.diff_desc);
143
145
  });
144
146
 
145
- it('returns similar specs for same-class same VRAM GPUs', () => {
146
- const desc = diffDescription('L40S', 'A6000'); // both 48GB, both datacenter
147
- expect(desc).toContain('similar specs');
147
+ it('returns empty string when diff_desc is absent', () => {
148
+ expect(diffDescription('A100', 'RTX_4090', {})).toBe('');
149
+ expect(diffDescription('A100', 'RTX_4090')).toBe('');
148
150
  });
149
151
 
150
- it('returns empty string for unknown GPUs', () => {
152
+ it('returns empty string for unknown GPUs with no alt object', () => {
151
153
  expect(diffDescription('UNKNOWN', 'ALSO_UNKNOWN')).toBe('');
152
154
  });
153
155
  });
@@ -1,10 +1,14 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import {
3
3
  findById, findByCanonical, findCheapest, listAll,
4
- getRoutePlan, estimateCost,
5
- GPU_CATALOG, PROVIDER_CATALOG,
4
+ estimateCost,
5
+ GPU_CATALOG,
6
6
  } from '../src/router.js';
7
7
 
8
+ // getRoutePlan and PROVIDER_CATALOG have been removed from the CLI.
9
+ // Provider routing is now server-side only. Tests below cover the
10
+ // remaining local GPU catalog helpers.
11
+
8
12
  describe('findById', () => {
9
13
  it('finds GPU by id', () => {
10
14
  expect(findById('rtx-4090').name).toContain('4090');
@@ -67,43 +71,6 @@ describe('listAll', () => {
67
71
  });
68
72
  });
69
73
 
70
- describe('getRoutePlan', () => {
71
- it('returns lane1 and lane2 for RTX_4090', () => {
72
- const plan = getRoutePlan('RTX_4090');
73
- expect(plan.lane1).toBeTruthy();
74
- expect(plan.lane2.length).toBeGreaterThan(0);
75
- });
76
-
77
- it('lane2 is sorted cheapest-first', () => {
78
- const plan = getRoutePlan('RTX_4090');
79
- for (let i = 1; i < plan.lane2.length; i++) {
80
- expect(plan.lane2[i].ratePerHour).toBeGreaterThanOrEqual(plan.lane2[i - 1].ratePerHour);
81
- }
82
- });
83
-
84
- it('scales ratePerHour by count', () => {
85
- const plan1 = getRoutePlan('RTX_4090', 1);
86
- const plan2 = getRoutePlan('RTX_4090', 2);
87
- expect(plan2.lane2[0].ratePerHour).toBeCloseTo(plan1.lane2[0].ratePerHour * 2, 5);
88
- });
89
-
90
- it('cheapestRate matches first provider rate', () => {
91
- const plan = getRoutePlan('H100');
92
- expect(plan.cheapestRate).toBe(plan.lane2[0].ratePerHour);
93
- });
94
-
95
- it('costWithOverhead > cheapestRate', () => {
96
- const plan = getRoutePlan('RTX_4090');
97
- expect(plan.costWithOverhead).toBeGreaterThan(plan.cheapestRate);
98
- });
99
-
100
- it('includes gpu info for known canonical', () => {
101
- const plan = getRoutePlan('H100');
102
- expect(plan.gpu).not.toBeNull();
103
- expect(plan.gpu.id).toBe('h100');
104
- });
105
- });
106
-
107
74
  describe('estimateCost', () => {
108
75
  it('calculates cost for 60 minutes = 1 hour', () => {
109
76
  expect(estimateCost(1.10, 60)).toBeCloseTo(1.10, 5);
@@ -124,34 +91,8 @@ describe('L40S', () => {
124
91
  expect(findByCanonical('L40S').vramGb).toBe(48);
125
92
  });
126
93
 
127
- it('has provider pricing in PROVIDER_CATALOG', () => {
128
- expect(Array.isArray(PROVIDER_CATALOG['L40S'])).toBe(true);
129
- expect(PROVIDER_CATALOG['L40S'].length).toBeGreaterThan(0);
130
- });
131
-
132
- it('route plan is cheapest-first for L40S', () => {
133
- const plan = getRoutePlan('L40S');
134
- expect(plan.lane2.length).toBeGreaterThan(0);
135
- for (let i = 1; i < plan.lane2.length; i++) {
136
- expect(plan.lane2[i].ratePerHour).toBeGreaterThanOrEqual(plan.lane2[i - 1].ratePerHour);
137
- }
138
- });
139
- });
140
-
141
- describe('PROVIDER_CATALOG', () => {
142
- it('exists for all GPU_CATALOG canonical types', () => {
143
- const catalogCanonicals = [...new Set(GPU_CATALOG.map(g => g.canonical))];
144
- catalogCanonicals.forEach(c => {
145
- // Some may not have provider pricing — just check the ones that do are valid arrays
146
- if (PROVIDER_CATALOG[c]) {
147
- expect(Array.isArray(PROVIDER_CATALOG[c])).toBe(true);
148
- PROVIDER_CATALOG[c].forEach(p => {
149
- expect(p.provider).toBeTruthy();
150
- expect(p.ratePerHour).toBeGreaterThan(0);
151
- expect(p.reliability).toBeGreaterThan(0);
152
- expect(p.reliability).toBeLessThanOrEqual(1);
153
- });
154
- }
155
- });
94
+ it('has no PROVIDER_CATALOG (provider routing is server-side)', async () => {
95
+ const mod = await import('../src/router.js');
96
+ expect(mod.PROVIDER_CATALOG).toBeUndefined();
156
97
  });
157
98
  });