badgr-cli 1.0.31 → 1.0.32
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/README.md +37 -18
- package/package.json +12 -4
- package/src/api.js +138 -20
- package/src/badgr.js +81 -37
- package/src/commands/billing.js +93 -0
- package/src/commands/capacity.js +111 -0
- package/src/commands/down.js +26 -23
- package/src/commands/login.js +23 -7
- package/src/commands/logs.js +57 -5
- package/src/commands/models.js +25 -6
- package/src/commands/receipts.js +19 -4
- package/src/commands/run.js +548 -90
- package/src/commands/serve.js +284 -66
- package/src/commands/status.js +35 -48
- package/src/commands/test-run.js +240 -0
- package/src/commands/up.js +32 -26
- package/src/config.js +49 -4
- package/src/fallback.js +179 -0
- package/src/router.js +16 -73
- package/src/store.js +10 -1
- package/tests/commands.test.js +234 -2
- package/tests/config.test.js +24 -1
- package/tests/router.test.js +9 -68
- package/tests/run-lifecycle.test.js +498 -0
- package/tests/serve-lifecycle.test.js +499 -0
- package/tests/store.test.js +41 -1
package/src/router.js
CHANGED
|
@@ -1,56 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GPU catalog
|
|
2
|
+
* GPU catalog — static display info only (name, VRAM, tags, indicative rate).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
export const GPU_CATALOG = [
|
|
10
|
-
{ id: 'rtx-3080', canonical: 'RTX_3080',
|
|
11
|
-
{ id: 'rtx-
|
|
12
|
-
{ id: '
|
|
13
|
-
{ id: '
|
|
14
|
-
{ id: '
|
|
15
|
-
{ id: '
|
|
16
|
-
{ id: '
|
|
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'] },
|
|
17
22
|
];
|
|
18
23
|
|
|
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
24
|
export function findById(id) {
|
|
55
25
|
return GPU_CATALOG.find(g => g.id === id) ?? null;
|
|
56
26
|
}
|
|
@@ -72,33 +42,6 @@ export function listAll() {
|
|
|
72
42
|
return [...GPU_CATALOG].sort((a, b) => a.ratePerHour - b.ratePerHour);
|
|
73
43
|
}
|
|
74
44
|
|
|
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
45
|
export function estimateCost(ratePerHour, durationMinutes) {
|
|
103
46
|
return (ratePerHour / 60) * durationMinutes;
|
|
104
47
|
}
|
package/src/store.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Local deployment state — persisted to ~/.
|
|
2
|
+
* Local deployment state — persisted to ~/.badgr/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,6 +83,15 @@ 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
|
+
|
|
86
95
|
export function listReceipts(limit = 20, storeFile = STORE_FILE) {
|
|
87
96
|
return loadStore(storeFile).receipts.slice(0, limit);
|
|
88
97
|
}
|
package/tests/commands.test.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { parseRunArgs } from '../src/commands/run.js';
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { parseRunArgs, classifyFailure } 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, CapacityError } from '../src/fallback.js';
|
|
4
6
|
|
|
5
7
|
describe('parseRunArgs', () => {
|
|
6
8
|
it('parses a plain command', () => {
|
|
@@ -43,6 +45,124 @@ describe('parseRunArgs', () => {
|
|
|
43
45
|
expect(positional).toEqual([]);
|
|
44
46
|
expect(flags.gpu).toBeUndefined();
|
|
45
47
|
});
|
|
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
|
+
});
|
|
46
166
|
});
|
|
47
167
|
|
|
48
168
|
describe('parseServeArgs', () => {
|
|
@@ -78,4 +198,116 @@ describe('parseServeArgs', () => {
|
|
|
78
198
|
const { model } = parseServeArgs(['--gpu', 'RTX_4090']);
|
|
79
199
|
expect(model).toBeNull();
|
|
80
200
|
});
|
|
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
|
+
it('parses --no-fallback as noMarketplaceFallback', () => {
|
|
213
|
+
const { flags } = parseServeArgs(['my/model', '--no-fallback']);
|
|
214
|
+
expect(flags.noMarketplaceFallback).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('parses --strict-capacity as noMarketplaceFallback', () => {
|
|
218
|
+
const { flags } = parseServeArgs(['my/model', '--strict-capacity']);
|
|
219
|
+
expect(flags.noMarketplaceFallback).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('parses --no-expanded-search as noMarketplaceFallback', () => {
|
|
223
|
+
const { flags } = parseServeArgs(['my/model', '--no-expanded-search']);
|
|
224
|
+
expect(flags.noMarketplaceFallback).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('noMarketplaceFallback defaults to falsy when not passed', () => {
|
|
228
|
+
const { flags } = parseServeArgs(['my/model']);
|
|
229
|
+
expect(flags.noMarketplaceFallback).toBeFalsy();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('model is not consumed by --no-fallback flag', () => {
|
|
233
|
+
const { model, flags } = parseServeArgs(['my/model', '--no-fallback']);
|
|
234
|
+
expect(model).toBe('my/model');
|
|
235
|
+
expect(flags.noMarketplaceFallback).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('parses --health-path flag', () => {
|
|
239
|
+
const { flags } = parseServeArgs(['my/model', '--health-path', '/system_stats']);
|
|
240
|
+
expect(flags.healthPath).toBe('/system_stats');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('parses --health-path with arbitrary path', () => {
|
|
244
|
+
const { flags } = parseServeArgs(['--image', 'my/image:latest', '--health-path', '/health']);
|
|
245
|
+
expect(flags.healthPath).toBe('/health');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('healthPath defaults to undefined when not passed', () => {
|
|
249
|
+
const { flags } = parseServeArgs(['my/model']);
|
|
250
|
+
expect(flags.healthPath).toBeUndefined();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('testCommand', () => {
|
|
255
|
+
it('is a function', () => {
|
|
256
|
+
expect(typeof testCommand).toBe('function');
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('parseTestArgs', () => {
|
|
261
|
+
it('returns empty flags for no args', () => {
|
|
262
|
+
expect(parseTestArgs([])).toEqual({});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('parses --provider tier1', () => {
|
|
266
|
+
expect(parseTestArgs(['--provider', 'tier1'])).toEqual({ provider: 'tier1' });
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('parses --provider tier2', () => {
|
|
270
|
+
expect(parseTestArgs(['--provider', 'tier2'])).toEqual({ provider: 'tier2' });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('parses --provider secondary', () => {
|
|
274
|
+
expect(parseTestArgs(['--provider', 'secondary'])).toEqual({ provider: 'secondary' });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('parses --no-tier-fallback', () => {
|
|
278
|
+
expect(parseTestArgs(['--no-tier-fallback'])).toEqual({ noTierFallback: true });
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe('CapacityError', () => {
|
|
283
|
+
it('is an Error subclass', () => {
|
|
284
|
+
const err = new CapacityError('no capacity');
|
|
285
|
+
expect(err).toBeInstanceOf(Error);
|
|
286
|
+
expect(err.isCapacityError).toBe(true);
|
|
287
|
+
expect(err.name).toBe('CapacityError');
|
|
288
|
+
expect(err.message).toBe('no capacity');
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe('promptFallback output', () => {
|
|
293
|
+
it('does not print a Difference line', async () => {
|
|
294
|
+
const lines = [];
|
|
295
|
+
const chalk = { yellow: s => s, bold: s => s, cyan: s => s, green: s => s, dim: s => s };
|
|
296
|
+
const origLog = console.log;
|
|
297
|
+
console.log = (...args) => lines.push(args.join(' '));
|
|
298
|
+
// force non-TTY so it auto-selects without asking
|
|
299
|
+
const origIsTTY = process.stdin.isTTY;
|
|
300
|
+
process.stdin.isTTY = false;
|
|
301
|
+
|
|
302
|
+
const pool = [{ gpu: 'L40S', region: 'US', price: 1.25 }];
|
|
303
|
+
await promptFallback('A100', pool, chalk);
|
|
304
|
+
|
|
305
|
+
console.log = origLog;
|
|
306
|
+
process.stdin.isTTY = origIsTTY;
|
|
307
|
+
|
|
308
|
+
const joined = lines.join('\n');
|
|
309
|
+
expect(joined).not.toContain('Difference');
|
|
310
|
+
expect(joined).toContain('L40S');
|
|
311
|
+
expect(joined).toContain('1.25');
|
|
312
|
+
});
|
|
81
313
|
});
|
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, DEFAULTS } from '../src/config.js';
|
|
5
|
+
import { loadConfig, saveConfig, requireApiKey, normalizeBaseUrl, 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,6 +58,29 @@ 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
|
+
|
|
61
84
|
describe('requireApiKey', () => {
|
|
62
85
|
it('returns the key when present', () => {
|
|
63
86
|
expect(requireApiKey({ apiKey: 'sk-abc' })).toBe('sk-abc');
|
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
|
});
|