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 +1 -1
- package/src/commands/run.js +11 -2
- package/src/commands/up.js +32 -26
- package/src/fallback.js +34 -63
- package/src/router.js +8 -69
- package/tests/commands.test.js +30 -28
- package/tests/router.test.js +9 -68
package/package.json
CHANGED
package/src/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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`));
|
package/src/commands/up.js
CHANGED
|
@@ -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
|
-
|
|
6
|
+
function printLivePlan(suggestions, gpu, chalk) {
|
|
7
|
+
const { matches = [], alternatives = [] } = suggestions;
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* mode '
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
55
|
-
*
|
|
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
|
-
|
|
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
|
|
86
|
-
const others
|
|
87
|
-
const
|
|
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 (
|
|
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
|
|
107
|
-
const hint =
|
|
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
|
|
2
|
+
* GPU catalog — static specs only (name, VRAM, tags, indicative rate).
|
|
3
3
|
*
|
|
4
|
-
* Provider pricing
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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',
|
|
15
|
-
{ id: 'a100-80gb', canonical: 'A100', name: 'NVIDIA A100 80GB',
|
|
16
|
-
{ id: 'h100', canonical: 'H100', name: 'NVIDIA H100 80GB',
|
|
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
|
}
|
package/tests/commands.test.js
CHANGED
|
@@ -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
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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('
|
|
131
|
-
const
|
|
132
|
-
expect(
|
|
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
|
|
146
|
-
|
|
147
|
-
expect(
|
|
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
|
});
|
package/tests/router.test.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import {
|
|
3
3
|
findById, findByCanonical, findCheapest, listAll,
|
|
4
|
-
|
|
5
|
-
GPU_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
|
|
128
|
-
|
|
129
|
-
expect(PROVIDER_CATALOG
|
|
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
|
});
|