badgr-cli 1.0.29 → 1.0.31
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/HOW_IT_WORKS.md +4 -4
- package/package.json +4 -12
- package/src/api.js +10 -87
- package/src/badgr.js +37 -67
- package/src/commands/down.js +23 -26
- package/src/commands/login.js +7 -23
- package/src/commands/logs.js +5 -57
- package/src/commands/models.js +6 -25
- package/src/commands/receipts.js +4 -19
- package/src/commands/run.js +84 -489
- package/src/commands/serve.js +51 -146
- package/src/commands/status.js +48 -35
- package/src/commands/up.js +26 -32
- package/src/config.js +4 -49
- package/src/router.js +73 -16
- package/src/store.js +1 -10
- package/tests/commands.test.js +2 -183
- package/tests/config.test.js +1 -24
- package/tests/router.test.js +68 -9
- package/tests/store.test.js +1 -41
- package/src/commands/billing.js +0 -93
- package/src/commands/capacity.js +0 -111
- package/src/commands/test-run.js +0 -240
- package/src/fallback.js +0 -95
package/src/router.js
CHANGED
|
@@ -1,26 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GPU catalog
|
|
2
|
+
* GPU catalog and routing preview.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* Provider routing logic lives entirely in the backend.
|
|
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.
|
|
9
7
|
*/
|
|
10
8
|
|
|
11
9
|
export const GPU_CATALOG = [
|
|
12
|
-
{ id: 'rtx-3080', canonical: 'RTX_3080',
|
|
13
|
-
{ id: 'rtx-
|
|
14
|
-
{ id: '
|
|
15
|
-
{ id: '
|
|
16
|
-
{ id: '
|
|
17
|
-
{ id: '
|
|
18
|
-
{ id: '
|
|
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'] },
|
|
10
|
+
{ id: 'rtx-3080', canonical: 'RTX_3080', name: 'NVIDIA RTX 3080', vramGb: 10, ratePerHour: 0.35, tags: ['inference', 'dev'] },
|
|
11
|
+
{ id: 'rtx-4090', canonical: 'RTX_4090', name: 'NVIDIA RTX 4090', vramGb: 24, ratePerHour: 1.10, tags: ['inference', 'training', 'dev'] },
|
|
12
|
+
{ id: 'l40s', canonical: 'L40S', name: 'NVIDIA L40S', vramGb: 48, ratePerHour: 1.40, tags: ['inference', 'training'] },
|
|
13
|
+
{ 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'] },
|
|
22
17
|
];
|
|
23
18
|
|
|
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
|
+
|
|
24
54
|
export function findById(id) {
|
|
25
55
|
return GPU_CATALOG.find(g => g.id === id) ?? null;
|
|
26
56
|
}
|
|
@@ -42,6 +72,33 @@ export function listAll() {
|
|
|
42
72
|
return [...GPU_CATALOG].sort((a, b) => a.ratePerHour - b.ratePerHour);
|
|
43
73
|
}
|
|
44
74
|
|
|
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
|
+
|
|
45
102
|
export function estimateCost(ratePerHour, durationMinutes) {
|
|
46
103
|
return (ratePerHour / 60) * durationMinutes;
|
|
47
104
|
}
|
package/src/store.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Local deployment state — persisted to ~/.
|
|
2
|
+
* Local deployment state — persisted to ~/.gpu/deployments.json.
|
|
3
3
|
*
|
|
4
4
|
* Tracks what `gpu up` has provisioned so `gpu down/status/logs/receipts`
|
|
5
5
|
* have something to work with before a backend deployments API exists.
|
|
@@ -83,15 +83,6 @@ export function addReceipt(receipt, storeFile = STORE_FILE) {
|
|
|
83
83
|
return receipt;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
export function updateReceipt(receiptId, updates, storeFile = STORE_FILE) {
|
|
87
|
-
const store = loadStore(storeFile);
|
|
88
|
-
const idx = store.receipts.findIndex(r => r.receiptId === receiptId);
|
|
89
|
-
if (idx === -1) return null;
|
|
90
|
-
store.receipts[idx] = { ...store.receipts[idx], ...updates };
|
|
91
|
-
saveStore(store, storeFile);
|
|
92
|
-
return store.receipts[idx];
|
|
93
|
-
}
|
|
94
|
-
|
|
95
86
|
export function listReceipts(limit = 20, storeFile = STORE_FILE) {
|
|
96
87
|
return loadStore(storeFile).receipts.slice(0, limit);
|
|
97
88
|
}
|
package/tests/commands.test.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { describe, it, expect
|
|
2
|
-
import { parseRunArgs
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseRunArgs } from '../src/commands/run.js';
|
|
3
3
|
import { parseServeArgs } from '../src/commands/serve.js';
|
|
4
|
-
import { testCommand, parseTestArgs } from '../src/commands/test-run.js';
|
|
5
|
-
import { rankAlternatives, diffDescription, promptFallback } from '../src/fallback.js';
|
|
6
4
|
|
|
7
5
|
describe('parseRunArgs', () => {
|
|
8
6
|
it('parses a plain command', () => {
|
|
@@ -45,124 +43,6 @@ describe('parseRunArgs', () => {
|
|
|
45
43
|
expect(positional).toEqual([]);
|
|
46
44
|
expect(flags.gpu).toBeUndefined();
|
|
47
45
|
});
|
|
48
|
-
|
|
49
|
-
it('parses --fallback closest', () => {
|
|
50
|
-
const { flags } = parseRunArgs(['python', 'train.py', '--gpu', 'A100', '--fallback', 'closest']);
|
|
51
|
-
expect(flags.fallback).toBe('closest');
|
|
52
|
-
expect(flags.noFallback).toBeUndefined();
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('parses --fallback cheapest', () => {
|
|
56
|
-
const { flags } = parseRunArgs(['python', 'train.py', '--fallback', 'cheapest']);
|
|
57
|
-
expect(flags.fallback).toBe('cheapest');
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('parses --no-fallback', () => {
|
|
61
|
-
const { flags } = parseRunArgs(['python', 'train.py', '--gpu', 'A100', '--no-fallback']);
|
|
62
|
-
expect(flags.noFallback).toBe(true);
|
|
63
|
-
expect(flags.fallback).toBeUndefined();
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('parses --max-runtime as a float (minutes)', () => {
|
|
67
|
-
const { flags } = parseRunArgs(['python', 'train.py', '--max-runtime', '30']);
|
|
68
|
-
expect(flags.maxRuntime).toBe(30);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('parses --max-cost as a float (dollars)', () => {
|
|
72
|
-
const { flags } = parseRunArgs(['python', 'train.py', '--max-cost', '5.00']);
|
|
73
|
-
expect(flags.maxCost).toBe(5.0);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('parses --tier 2 flag', () => {
|
|
77
|
-
const { flags } = parseRunArgs(['python', 'train.py', '--tier', '2']);
|
|
78
|
-
expect(flags.tier).toBe('2');
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('--tier defaults to undefined when not passed', () => {
|
|
82
|
-
const { flags } = parseRunArgs(['python', 'train.py']);
|
|
83
|
-
expect(flags.tier).toBeUndefined();
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
describe('classifyFailure', () => {
|
|
88
|
-
it('returns infrastructure when status is failed and no exit code', () => {
|
|
89
|
-
expect(classifyFailure('failed', null)).toBe('infrastructure');
|
|
90
|
-
expect(classifyFailure('failed', undefined)).toBe('infrastructure');
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('returns customer_code when exit code is non-zero', () => {
|
|
94
|
-
expect(classifyFailure('failed', 1)).toBe('customer_code');
|
|
95
|
-
expect(classifyFailure('completed', 2)).toBe('customer_code');
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('returns null for successful jobs', () => {
|
|
99
|
-
expect(classifyFailure('completed', 0)).toBeNull();
|
|
100
|
-
expect(classifyFailure('completed', null)).toBeNull();
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('returns null for stopped jobs with no exit code', () => {
|
|
104
|
-
expect(classifyFailure('stopped', null)).toBeNull();
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
describe('rankAlternatives', () => {
|
|
109
|
-
const pool = [
|
|
110
|
-
{ gpu: 'RTX_4090', region: 'US', price: 0.72, rank: 3 },
|
|
111
|
-
{ gpu: 'L40S', region: 'US', price: 1.25, rank: 2 },
|
|
112
|
-
{ gpu: 'H100', region: 'EU', price: 2.80, rank: 1 },
|
|
113
|
-
{ gpu: 'A6000', region: 'US', price: 1.20 }, // no rank field
|
|
114
|
-
];
|
|
115
|
-
|
|
116
|
-
it('cheapest mode sorts by price ascending', () => {
|
|
117
|
-
const ranked = rankAlternatives('A100', pool, 'cheapest');
|
|
118
|
-
expect(ranked[0].price).toBe(0.72);
|
|
119
|
-
expect(ranked[ranked.length - 1].price).toBe(2.80);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('closest mode honours backend rank field when present', () => {
|
|
123
|
-
const poolWithRanks = [
|
|
124
|
-
{ gpu: 'RTX_4090', region: 'US', price: 0.72, rank: 3 },
|
|
125
|
-
{ gpu: 'L40S', region: 'US', price: 1.25, rank: 2 },
|
|
126
|
-
{ gpu: 'H100', region: 'EU', price: 2.80, rank: 1 },
|
|
127
|
-
];
|
|
128
|
-
const ranked = rankAlternatives('A100', poolWithRanks, 'closest');
|
|
129
|
-
expect(ranked[0].gpu).toBe('H100'); // rank 1 → top
|
|
130
|
-
expect(ranked[1].gpu).toBe('L40S'); // rank 2
|
|
131
|
-
expect(ranked[2].gpu).toBe('RTX_4090'); // rank 3
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('closest mode falls back to price sort when no rank field', () => {
|
|
135
|
-
const noRankPool = [
|
|
136
|
-
{ gpu: 'RTX_4090', region: 'US', price: 0.72 },
|
|
137
|
-
{ gpu: 'L40S', region: 'US', price: 1.25 },
|
|
138
|
-
];
|
|
139
|
-
const ranked = rankAlternatives('A100', noRankPool, 'closest');
|
|
140
|
-
expect(ranked[0].price).toBe(0.72);
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('returns empty array for empty pool', () => {
|
|
144
|
-
expect(rankAlternatives('A100', [])).toEqual([]);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('returns empty array for null pool', () => {
|
|
148
|
-
expect(rankAlternatives('A100', null)).toEqual([]);
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
describe('diffDescription', () => {
|
|
153
|
-
it('uses diff_desc from alt object when backend provides it', () => {
|
|
154
|
-
const alt = { diff_desc: 'less VRAM (24GB vs 40GB) than A100' };
|
|
155
|
-
expect(diffDescription('A100', 'RTX_4090', alt)).toBe(alt.diff_desc);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('returns empty string when diff_desc is absent', () => {
|
|
159
|
-
expect(diffDescription('A100', 'RTX_4090', {})).toBe('');
|
|
160
|
-
expect(diffDescription('A100', 'RTX_4090')).toBe('');
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it('returns empty string for unknown GPUs with no alt object', () => {
|
|
164
|
-
expect(diffDescription('UNKNOWN', 'ALSO_UNKNOWN')).toBe('');
|
|
165
|
-
});
|
|
166
46
|
});
|
|
167
47
|
|
|
168
48
|
describe('parseServeArgs', () => {
|
|
@@ -198,65 +78,4 @@ describe('parseServeArgs', () => {
|
|
|
198
78
|
const { model } = parseServeArgs(['--gpu', 'RTX_4090']);
|
|
199
79
|
expect(model).toBeNull();
|
|
200
80
|
});
|
|
201
|
-
|
|
202
|
-
it('parses --tier 2 flag', () => {
|
|
203
|
-
const { flags } = parseServeArgs(['my/model', '--tier', '2']);
|
|
204
|
-
expect(flags.tier).toBe('2');
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it('--tier defaults to undefined when not passed', () => {
|
|
208
|
-
const { flags } = parseServeArgs(['my/model']);
|
|
209
|
-
expect(flags.tier).toBeUndefined();
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
describe('testCommand', () => {
|
|
214
|
-
it('is a function', () => {
|
|
215
|
-
expect(typeof testCommand).toBe('function');
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
describe('parseTestArgs', () => {
|
|
220
|
-
it('returns empty flags for no args', () => {
|
|
221
|
-
expect(parseTestArgs([])).toEqual({});
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it('parses --provider tier1', () => {
|
|
225
|
-
expect(parseTestArgs(['--provider', 'tier1'])).toEqual({ provider: 'tier1' });
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it('parses --provider tier2', () => {
|
|
229
|
-
expect(parseTestArgs(['--provider', 'tier2'])).toEqual({ provider: 'tier2' });
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it('parses --provider secondary', () => {
|
|
233
|
-
expect(parseTestArgs(['--provider', 'secondary'])).toEqual({ provider: 'secondary' });
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
it('parses --no-tier-fallback', () => {
|
|
237
|
-
expect(parseTestArgs(['--no-tier-fallback'])).toEqual({ noTierFallback: true });
|
|
238
|
-
});
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
describe('promptFallback output', () => {
|
|
242
|
-
it('does not print a Difference line', async () => {
|
|
243
|
-
const lines = [];
|
|
244
|
-
const chalk = { yellow: s => s, bold: s => s, cyan: s => s, green: s => s, dim: s => s };
|
|
245
|
-
const origLog = console.log;
|
|
246
|
-
console.log = (...args) => lines.push(args.join(' '));
|
|
247
|
-
// force non-TTY so it auto-selects without asking
|
|
248
|
-
const origIsTTY = process.stdin.isTTY;
|
|
249
|
-
process.stdin.isTTY = false;
|
|
250
|
-
|
|
251
|
-
const pool = [{ gpu: 'L40S', region: 'US', price: 1.25 }];
|
|
252
|
-
await promptFallback('A100', pool, chalk);
|
|
253
|
-
|
|
254
|
-
console.log = origLog;
|
|
255
|
-
process.stdin.isTTY = origIsTTY;
|
|
256
|
-
|
|
257
|
-
const joined = lines.join('\n');
|
|
258
|
-
expect(joined).not.toContain('Difference');
|
|
259
|
-
expect(joined).toContain('L40S');
|
|
260
|
-
expect(joined).toContain('1.25');
|
|
261
|
-
});
|
|
262
81
|
});
|
package/tests/config.test.js
CHANGED
|
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest';
|
|
|
2
2
|
import { tmpdir } from 'os';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { rmSync, existsSync } from 'fs';
|
|
5
|
-
import { loadConfig, saveConfig, requireApiKey,
|
|
5
|
+
import { loadConfig, saveConfig, requireApiKey, DEFAULTS } from '../src/config.js';
|
|
6
6
|
|
|
7
7
|
const tmp = join(tmpdir(), `badgr-cli-test-${process.pid}`);
|
|
8
8
|
const testConfigFile = join(tmp, 'config.json');
|
|
@@ -58,29 +58,6 @@ describe('saveConfig', () => {
|
|
|
58
58
|
});
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
describe('normalizeBaseUrl', () => {
|
|
62
|
-
it('rewrites legacy api.badgr.ai to production host', () => {
|
|
63
|
-
expect(normalizeBaseUrl('https://api.badgr.ai/v1')).toBe(DEFAULTS.baseUrl);
|
|
64
|
-
expect(normalizeBaseUrl('https://api.badgr.ai')).toBe(DEFAULTS.baseUrl);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('keeps production and localhost URLs', () => {
|
|
68
|
-
expect(normalizeBaseUrl('https://api.aibadgr.com/v1')).toBe(DEFAULTS.baseUrl);
|
|
69
|
-
expect(normalizeBaseUrl('http://localhost:8000/v1')).toBe('http://localhost:8000/v1');
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
describe('loadConfig legacy migration', () => {
|
|
74
|
-
it('migrates saved api.badgr.ai baseUrl on load', () => {
|
|
75
|
-
saveConfig(
|
|
76
|
-
{ apiKey: 'sk-test', baseUrl: 'https://api.badgr.ai/v1' },
|
|
77
|
-
testConfigFile,
|
|
78
|
-
);
|
|
79
|
-
const config = loadConfig(testConfigFile);
|
|
80
|
-
expect(config.baseUrl).toBe(DEFAULTS.baseUrl);
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
61
|
describe('requireApiKey', () => {
|
|
85
62
|
it('returns the key when present', () => {
|
|
86
63
|
expect(requireApiKey({ apiKey: 'sk-abc' })).toBe('sk-abc');
|
package/tests/router.test.js
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import {
|
|
3
3
|
findById, findByCanonical, findCheapest, listAll,
|
|
4
|
-
estimateCost,
|
|
5
|
-
GPU_CATALOG,
|
|
4
|
+
getRoutePlan, estimateCost,
|
|
5
|
+
GPU_CATALOG, PROVIDER_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
|
-
|
|
12
8
|
describe('findById', () => {
|
|
13
9
|
it('finds GPU by id', () => {
|
|
14
10
|
expect(findById('rtx-4090').name).toContain('4090');
|
|
@@ -71,6 +67,43 @@ describe('listAll', () => {
|
|
|
71
67
|
});
|
|
72
68
|
});
|
|
73
69
|
|
|
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
|
+
|
|
74
107
|
describe('estimateCost', () => {
|
|
75
108
|
it('calculates cost for 60 minutes = 1 hour', () => {
|
|
76
109
|
expect(estimateCost(1.10, 60)).toBeCloseTo(1.10, 5);
|
|
@@ -91,8 +124,34 @@ describe('L40S', () => {
|
|
|
91
124
|
expect(findByCanonical('L40S').vramGb).toBe(48);
|
|
92
125
|
});
|
|
93
126
|
|
|
94
|
-
it('has
|
|
95
|
-
|
|
96
|
-
expect(
|
|
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
|
+
});
|
|
97
156
|
});
|
|
98
157
|
});
|
package/tests/store.test.js
CHANGED
|
@@ -5,7 +5,7 @@ import { rmSync, existsSync } from 'fs';
|
|
|
5
5
|
import {
|
|
6
6
|
loadStore, saveStore,
|
|
7
7
|
addDeployment, updateDeployment, removeDeployment, findDeployment, listDeployments,
|
|
8
|
-
addReceipt,
|
|
8
|
+
addReceipt, listReceipts,
|
|
9
9
|
generateDeploymentId, generateReceiptId,
|
|
10
10
|
} from '../src/store.js';
|
|
11
11
|
|
|
@@ -124,43 +124,3 @@ describe('addReceipt / listReceipts', () => {
|
|
|
124
124
|
expect(listReceipts(3, file)).toHaveLength(3);
|
|
125
125
|
});
|
|
126
126
|
});
|
|
127
|
-
|
|
128
|
-
describe('updateReceipt', () => {
|
|
129
|
-
it('merges updates into an existing receipt', () => {
|
|
130
|
-
addReceipt({ receiptId: 'r-upd', action: 'badgr run', status: 'running' }, file);
|
|
131
|
-
updateReceipt('r-upd', { status: 'completed', finalCost: 0.012, runtimeSeconds: 42 }, file);
|
|
132
|
-
const [r] = listReceipts(1, file);
|
|
133
|
-
expect(r.status).toBe('completed');
|
|
134
|
-
expect(r.finalCost).toBe(0.012);
|
|
135
|
-
expect(r.runtimeSeconds).toBe(42);
|
|
136
|
-
expect(r.action).toBe('badgr run'); // preserved
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it('returns null for unknown receipt id', () => {
|
|
140
|
-
expect(updateReceipt('no-such-id', { status: 'done' }, file)).toBeNull();
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('records a serve failure receipt with failureType=infrastructure', () => {
|
|
144
|
-
addReceipt({
|
|
145
|
-
receiptId: 'r-serve-fail',
|
|
146
|
-
action: 'badgr serve',
|
|
147
|
-
model: 'meta-llama/Llama-3.1-8B-Instruct',
|
|
148
|
-
gpu: 'L40S',
|
|
149
|
-
status: 'failed',
|
|
150
|
-
failureType: 'infrastructure',
|
|
151
|
-
createdAt: new Date().toISOString(),
|
|
152
|
-
}, file);
|
|
153
|
-
const [r] = listReceipts(1, file);
|
|
154
|
-
expect(r.failureType).toBe('infrastructure');
|
|
155
|
-
expect(r.action).toBe('badgr serve');
|
|
156
|
-
expect(r.status).toBe('failed');
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it('updates a serve receipt to health_check_timeout', () => {
|
|
160
|
-
addReceipt({ receiptId: 'r-hc', action: 'badgr serve', status: 'provisioning' }, file);
|
|
161
|
-
updateReceipt('r-hc', { status: 'health_check_timeout' }, file);
|
|
162
|
-
const receipts = listReceipts(10, file);
|
|
163
|
-
const r = receipts.find(x => x.receiptId === 'r-hc');
|
|
164
|
-
expect(r.status).toBe('health_check_timeout');
|
|
165
|
-
});
|
|
166
|
-
});
|
package/src/commands/billing.js
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import { execSync } from 'child_process';
|
|
2
|
-
import { requireApiKey } from '../config.js';
|
|
3
|
-
import { callApi } from '../api.js';
|
|
4
|
-
|
|
5
|
-
const BILLING_HELP = `
|
|
6
|
-
badgr billing — manage your AI Badgr balance
|
|
7
|
-
|
|
8
|
-
COMMANDS
|
|
9
|
-
badgr billing status Show current balance
|
|
10
|
-
badgr billing add <amount> Open checkout to add balance (minimum $10)
|
|
11
|
-
|
|
12
|
-
EXAMPLES
|
|
13
|
-
badgr billing status
|
|
14
|
-
badgr billing add 10
|
|
15
|
-
badgr billing add 20
|
|
16
|
-
badgr billing add 50
|
|
17
|
-
`;
|
|
18
|
-
|
|
19
|
-
function openBrowser(url) {
|
|
20
|
-
const platform = process.platform;
|
|
21
|
-
try {
|
|
22
|
-
if (platform === 'darwin') execSync(`open "${url}"`);
|
|
23
|
-
else if (platform === 'win32') execSync(`start "" "${url}"`);
|
|
24
|
-
else execSync(`xdg-open "${url}"`);
|
|
25
|
-
} catch {
|
|
26
|
-
// Silently ignore — we print the URL anyway
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
async function billingStatus(config, chalk) {
|
|
31
|
-
requireApiKey(config);
|
|
32
|
-
try {
|
|
33
|
-
const apiUrl = config.baseUrl.replace('/v1', '').replace('/api/v1', '');
|
|
34
|
-
const data = await callApi('/api/me', {
|
|
35
|
-
apiKey: config.apiKey,
|
|
36
|
-
baseUrl: apiUrl,
|
|
37
|
-
});
|
|
38
|
-
const credits = data.credits ?? 0;
|
|
39
|
-
const balanceUsd = (credits / 10000).toFixed(2);
|
|
40
|
-
console.log();
|
|
41
|
-
console.log(chalk.bold(' Balance'));
|
|
42
|
-
console.log(` ${chalk.bold(chalk.blue(`$${balanceUsd}`))}`);
|
|
43
|
-
console.log();
|
|
44
|
-
if (credits === 0) {
|
|
45
|
-
console.log(chalk.yellow(' Balance is $0.00. Add balance before making API calls or running GPU jobs.'));
|
|
46
|
-
console.log(chalk.dim(' Add balance: badgr billing add 10'));
|
|
47
|
-
console.log(chalk.dim(' Or visit: https://aibadgr.com/billing/top-up'));
|
|
48
|
-
}
|
|
49
|
-
console.log();
|
|
50
|
-
} catch (err) {
|
|
51
|
-
if (err.isPaymentRequired) {
|
|
52
|
-
console.error(err.message);
|
|
53
|
-
process.exit(1);
|
|
54
|
-
}
|
|
55
|
-
console.error(chalk.red(' Could not fetch balance: ' + err.message));
|
|
56
|
-
process.exit(1);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async function billingAdd(config, amount, chalk) {
|
|
61
|
-
requireApiKey(config);
|
|
62
|
-
const amountInt = parseInt(amount, 10);
|
|
63
|
-
if (!amountInt || amountInt < 10) {
|
|
64
|
-
console.error(chalk.red(' Minimum top-up is $10. Example: badgr billing add 10'));
|
|
65
|
-
process.exit(1);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const url = `https://aibadgr.com/dashboard#billing`;
|
|
69
|
-
console.log();
|
|
70
|
-
console.log(chalk.bold(` Opening dashboard billing to add $${amountInt}...`));
|
|
71
|
-
console.log();
|
|
72
|
-
console.log(` ${chalk.dim(url)}`);
|
|
73
|
-
console.log();
|
|
74
|
-
openBrowser(url);
|
|
75
|
-
console.log(chalk.dim(' Complete payment in your browser, then rerun your command.'));
|
|
76
|
-
console.log();
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export async function billingCommand(config, args, chalk) {
|
|
80
|
-
const [sub, ...rest] = args;
|
|
81
|
-
|
|
82
|
-
if (!sub || sub === '--help' || sub === '-h') {
|
|
83
|
-
console.log(BILLING_HELP);
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (sub === 'status') return billingStatus(config, chalk);
|
|
88
|
-
if (sub === 'add') return billingAdd(config, rest[0], chalk);
|
|
89
|
-
|
|
90
|
-
console.error(chalk.red(` Unknown billing command: ${sub}`));
|
|
91
|
-
console.log(chalk.dim(' Run: badgr billing --help'));
|
|
92
|
-
process.exit(1);
|
|
93
|
-
}
|