badgr-cli 1.0.31 → 1.0.34
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 +57 -27
- package/package.json +14 -6
- 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 +23 -4
- package/src/commands/run.js +551 -86
- package/src/commands/serve.js +343 -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/errors.js +299 -0
- package/src/fallback.js +219 -0
- package/src/router.js +17 -73
- package/src/store.js +10 -1
- package/tests/commands.test.js +246 -2
- package/tests/config.test.js +24 -1
- package/tests/errors.test.js +130 -0
- package/tests/launch-readiness.test.js +326 -0
- package/tests/router.test.js +9 -68
- package/tests/run-lifecycle.test.js +508 -0
- package/tests/serve-lifecycle.test.js +499 -0
- package/tests/store.test.js +41 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the unified error catalog (errors.js).
|
|
3
|
+
* Verifies that every catalog entry produces output matching the four CLI guarantees:
|
|
4
|
+
* 1. What happened (code + message in output)
|
|
5
|
+
* 2. Retry status (retried note when entry.retried === true)
|
|
6
|
+
* 3. Billing status (billing label in output)
|
|
7
|
+
* 4. Next step (at least one → hint in output)
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
import { CATALOG, formatCliError } from '../src/errors.js';
|
|
11
|
+
|
|
12
|
+
// Passthrough chalk mock — identical to what the lifecycle test suite uses.
|
|
13
|
+
const chalk = new Proxy({}, {
|
|
14
|
+
get: () => (s) => s,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('CATALOG completeness', () => {
|
|
18
|
+
const REQUIRED_FIELDS = ['message', 'billing', 'retried', 'hint', 'severity'];
|
|
19
|
+
const VALID_BILLING = ['never_started', 'stopped', 'check_receipt', 'charged'];
|
|
20
|
+
const VALID_SEVERITY = ['P1', 'P2', 'P3', 'P4'];
|
|
21
|
+
|
|
22
|
+
for (const [code, entry] of Object.entries(CATALOG)) {
|
|
23
|
+
it(`${code} has all required fields`, () => {
|
|
24
|
+
for (const f of REQUIRED_FIELDS) {
|
|
25
|
+
expect(entry, `${code} missing field: ${f}`).toHaveProperty(f);
|
|
26
|
+
}
|
|
27
|
+
expect(VALID_BILLING, `${code}.billing invalid`).toContain(entry.billing);
|
|
28
|
+
expect(VALID_SEVERITY, `${code}.severity invalid`).toContain(entry.severity);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('formatCliError', () => {
|
|
34
|
+
it('includes the error code in the output', () => {
|
|
35
|
+
const out = formatCliError('NO_CAPACITY', {}, chalk);
|
|
36
|
+
expect(out).toContain('NO_CAPACITY');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('includes billing label in output', () => {
|
|
40
|
+
const out = formatCliError('NO_CAPACITY', {}, chalk);
|
|
41
|
+
expect(out).toContain('Billing: never started');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('includes a → hint line', () => {
|
|
45
|
+
const out = formatCliError('NO_CAPACITY', {}, chalk);
|
|
46
|
+
expect(out).toContain('→');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('shows "Badgr retried automatically" only for retried=true entries', () => {
|
|
50
|
+
const retried = formatCliError('PROVISIONING_FAILED', {}, chalk);
|
|
51
|
+
expect(retried).toContain('Badgr retried automatically');
|
|
52
|
+
|
|
53
|
+
const notRetried = formatCliError('NO_CAPACITY', {}, chalk);
|
|
54
|
+
expect(notRetried).not.toContain('Badgr retried automatically');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('does not expose internal fields by default', () => {
|
|
58
|
+
const out = formatCliError('PROVISIONING_FAILED', {}, chalk, { detail: 'SECRET_PROVIDER_URL' });
|
|
59
|
+
expect(out).not.toContain('SECRET_PROVIDER_URL');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('exposes internal fields when BADGR_DEBUG=1', () => {
|
|
63
|
+
process.env.BADGR_DEBUG = '1';
|
|
64
|
+
try {
|
|
65
|
+
const out = formatCliError('PROVISIONING_FAILED', {}, chalk, { detail: 'SECRET_PROVIDER_URL' });
|
|
66
|
+
expect(out).toContain('SECRET_PROVIDER_URL');
|
|
67
|
+
} finally {
|
|
68
|
+
delete process.env.BADGR_DEBUG;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('falls back gracefully for unknown codes', () => {
|
|
73
|
+
const out = formatCliError('TOTALLY_UNKNOWN_CODE', {}, chalk);
|
|
74
|
+
expect(out).toContain('TOTALLY_UNKNOWN_CODE');
|
|
75
|
+
expect(out).toContain('unexpected error');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ── Specific catalog entries ──────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
it('PROVISIONING_FAILED compat_failure contains incompatib and GPU type/image', () => {
|
|
81
|
+
const out = formatCliError('PROVISIONING_FAILED', { failure_category: 'compat_failure' }, chalk);
|
|
82
|
+
expect(out).toContain('incompatib');
|
|
83
|
+
expect(out).toMatch(/GPU type|image/i);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('PROVISIONING_FAILED generic contains "failed to start" and "try again"', () => {
|
|
87
|
+
const out = formatCliError('PROVISIONING_FAILED', {}, chalk);
|
|
88
|
+
expect(out).toContain('failed to start');
|
|
89
|
+
expect(out).toMatch(/try again/i);
|
|
90
|
+
expect(out).not.toContain('incompatib');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('NO_CAPACITY output contains "capacity" (satisfies /capacity/i test)', () => {
|
|
94
|
+
const out = formatCliError('NO_CAPACITY', {}, chalk);
|
|
95
|
+
expect(out).toMatch(/capacity/i);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('TEARDOWN_FAILED is P1 severity', () => {
|
|
99
|
+
expect(CATALOG.TEARDOWN_FAILED.severity).toBe('P1');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('TEARDOWN_FAILED billing is check_receipt', () => {
|
|
103
|
+
expect(CATALOG.TEARDOWN_FAILED.billing).toBe('check_receipt');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('TEARDOWN_FAILED output contains billing warning', () => {
|
|
107
|
+
const out = formatCliError('TEARDOWN_FAILED', { deploymentId: 'dep-123', receiptId: 'rcpt-456' }, chalk);
|
|
108
|
+
expect(out).toContain('check your receipt');
|
|
109
|
+
expect(out).toContain('dep-123');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('JOB_INFRASTRUCTURE_FAILURE billing is never_started', () => {
|
|
113
|
+
expect(CATALOG.JOB_INFRASTRUCTURE_FAILURE.billing).toBe('never_started');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('HEALTH_CHECK_FAILED billing is stopped', () => {
|
|
117
|
+
expect(CATALOG.HEALTH_CHECK_FAILED.billing).toBe('stopped');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('BILLING_INSUFFICIENT shows balance and required when provided', () => {
|
|
121
|
+
const out = formatCliError('BILLING_INSUFFICIENT', {
|
|
122
|
+
balance_usd: 1.23,
|
|
123
|
+
required_usd: 5.00,
|
|
124
|
+
topup_url: 'https://example.com/billing',
|
|
125
|
+
}, chalk);
|
|
126
|
+
expect(out).toContain('1.23');
|
|
127
|
+
expect(out).toContain('5.00');
|
|
128
|
+
expect(out).toContain('example.com/billing');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launch readiness — gap-filling tests for features on the outreach checklist
|
|
3
|
+
*
|
|
4
|
+
* Covers items missing from run-lifecycle and serve-lifecycle:
|
|
5
|
+
* - badgr down → terminates, writes receipt, prints cost summary
|
|
6
|
+
* - badgr status → shows running deployments, rate, endpoint; empty state
|
|
7
|
+
* - compat_failure error → correct user-facing message for CUDA mismatch
|
|
8
|
+
* - failure_category surfaced → compat failure receipt shows failure reason
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
12
|
+
|
|
13
|
+
// ── Module mocks ──────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
vi.mock('../src/api.js', () => ({
|
|
16
|
+
callApi: vi.fn(),
|
|
17
|
+
terminateDeployment: vi.fn(),
|
|
18
|
+
listDeployments: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock('../src/store.js', () => ({
|
|
22
|
+
addDeployment: vi.fn(),
|
|
23
|
+
addReceipt: vi.fn(),
|
|
24
|
+
updateReceipt: vi.fn(),
|
|
25
|
+
generateReceiptId: vi.fn(() => 'rcpt-down-001'),
|
|
26
|
+
generateDeploymentId: vi.fn(() => 'dep-down-001'),
|
|
27
|
+
listDeployments: vi.fn(() => []),
|
|
28
|
+
listReceipts: vi.fn(() => []),
|
|
29
|
+
findDeployment: vi.fn(() => null),
|
|
30
|
+
updateDeployment: vi.fn(),
|
|
31
|
+
removeDeployment: vi.fn(),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock('../src/config.js', () => ({
|
|
35
|
+
requireApiKey: vi.fn(),
|
|
36
|
+
loadConfig: vi.fn(() => ({ apiKey: 'sk-test', baseUrl: 'https://api.test/v1' })),
|
|
37
|
+
saveConfig: vi.fn(),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
import { downCommand } from '../src/commands/down.js';
|
|
41
|
+
import { statusCommand } from '../src/commands/status.js';
|
|
42
|
+
import { callWithFallback, CapacityError } from '../src/fallback.js';
|
|
43
|
+
import * as api from '../src/api.js';
|
|
44
|
+
import * as store from '../src/store.js';
|
|
45
|
+
|
|
46
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
|
|
49
|
+
|
|
50
|
+
const chalk = {
|
|
51
|
+
bold: s => s,
|
|
52
|
+
dim: s => s,
|
|
53
|
+
red: s => s,
|
|
54
|
+
yellow: s => s,
|
|
55
|
+
green: s => s,
|
|
56
|
+
cyan: s => s,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const NOW = 1_700_000_000; // fixed unix timestamp
|
|
60
|
+
const STARTED = NOW - 600; // 10 minutes ago
|
|
61
|
+
|
|
62
|
+
function makeTerminatedDep(overrides = {}) {
|
|
63
|
+
return {
|
|
64
|
+
deployment_id: 'dep-abc123',
|
|
65
|
+
gpu_type: 'RTX_4090',
|
|
66
|
+
cost_per_hour: 0.85,
|
|
67
|
+
started_at: STARTED,
|
|
68
|
+
stopped_at: NOW,
|
|
69
|
+
status: 'stopped',
|
|
70
|
+
...overrides,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function makeRunningDep(overrides = {}) {
|
|
75
|
+
return {
|
|
76
|
+
deployment_id: 'dep-run-001',
|
|
77
|
+
workload_type: 'job',
|
|
78
|
+
gpu_type: 'A100',
|
|
79
|
+
gpu_count: 1,
|
|
80
|
+
cost_per_hour: 2.50,
|
|
81
|
+
status: 'running',
|
|
82
|
+
endpoint_url: null,
|
|
83
|
+
model: null,
|
|
84
|
+
...overrides,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
90
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
91
|
+
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
92
|
+
vi.clearAllMocks();
|
|
93
|
+
store.generateReceiptId.mockReturnValue('rcpt-down-001');
|
|
94
|
+
store.findDeployment.mockReturnValue(null);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
vi.restoreAllMocks();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
102
|
+
// 1. badgr down
|
|
103
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
describe('badgr down', () => {
|
|
106
|
+
it('calls terminateDeployment with the provided deployment ID', async () => {
|
|
107
|
+
api.terminateDeployment.mockResolvedValue(makeTerminatedDep());
|
|
108
|
+
|
|
109
|
+
await downCommand(config, ['dep-abc123'], chalk);
|
|
110
|
+
|
|
111
|
+
expect(api.terminateDeployment).toHaveBeenCalledWith(config, 'dep-abc123');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('writes a receipt with runtime, finalCost, and action=badgr down', async () => {
|
|
115
|
+
api.terminateDeployment.mockResolvedValue(makeTerminatedDep());
|
|
116
|
+
|
|
117
|
+
await downCommand(config, ['dep-abc123'], chalk);
|
|
118
|
+
|
|
119
|
+
expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({
|
|
120
|
+
action: 'badgr down',
|
|
121
|
+
deploymentId: 'dep-abc123',
|
|
122
|
+
gpu: 'RTX_4090',
|
|
123
|
+
status: 'terminated',
|
|
124
|
+
}));
|
|
125
|
+
// Runtime should be ~600s (10 min window)
|
|
126
|
+
const call = store.addReceipt.mock.calls[0][0];
|
|
127
|
+
expect(call.runtimeSeconds).toBeGreaterThan(0);
|
|
128
|
+
expect(call.finalCost).toBeGreaterThan(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('removes the local deployment record', async () => {
|
|
132
|
+
api.terminateDeployment.mockResolvedValue(makeTerminatedDep());
|
|
133
|
+
|
|
134
|
+
await downCommand(config, ['dep-abc123'], chalk);
|
|
135
|
+
|
|
136
|
+
expect(store.removeDeployment).toHaveBeenCalledWith('dep-abc123');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('resolves local name to deployment ID via findDeployment', async () => {
|
|
140
|
+
store.findDeployment.mockReturnValue({ id: 'dep-abc123', name: 'my-job' });
|
|
141
|
+
api.terminateDeployment.mockResolvedValue(makeTerminatedDep());
|
|
142
|
+
|
|
143
|
+
await downCommand(config, ['my-job'], chalk);
|
|
144
|
+
|
|
145
|
+
expect(api.terminateDeployment).toHaveBeenCalledWith(config, 'dep-abc123');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('prints error and returns without crashing on API failure', async () => {
|
|
149
|
+
api.terminateDeployment.mockRejectedValue(new Error('network error'));
|
|
150
|
+
|
|
151
|
+
await downCommand(config, ['dep-abc123'], chalk);
|
|
152
|
+
|
|
153
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
154
|
+
expect.stringContaining('Could not stop deployment'),
|
|
155
|
+
);
|
|
156
|
+
expect(store.addReceipt).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('prints usage hint when no deployment ID given', async () => {
|
|
160
|
+
await downCommand(config, [], chalk);
|
|
161
|
+
|
|
162
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
163
|
+
expect.stringContaining('Usage'),
|
|
164
|
+
);
|
|
165
|
+
expect(api.terminateDeployment).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
170
|
+
// 2. badgr status
|
|
171
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
describe('badgr status', () => {
|
|
174
|
+
it('shows "Nothing running" when no active deployments', async () => {
|
|
175
|
+
api.listDeployments.mockResolvedValue({ deployments: [] });
|
|
176
|
+
|
|
177
|
+
await statusCommand(config, [], chalk);
|
|
178
|
+
|
|
179
|
+
const allOutput = console.log.mock.calls.flat().join(' ');
|
|
180
|
+
expect(allOutput).toContain('Nothing running');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('displays running deployment with GPU type and billing rate', async () => {
|
|
184
|
+
api.listDeployments.mockResolvedValue({ deployments: [makeRunningDep()] });
|
|
185
|
+
|
|
186
|
+
await statusCommand(config, [], chalk);
|
|
187
|
+
|
|
188
|
+
const allOutput = console.log.mock.calls.flat().join(' ');
|
|
189
|
+
expect(allOutput).toContain('dep-run-001');
|
|
190
|
+
expect(allOutput).toContain('A100');
|
|
191
|
+
expect(allOutput).toContain('2.50');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('shows endpoint URL for endpoint workloads', async () => {
|
|
195
|
+
api.listDeployments.mockResolvedValue({
|
|
196
|
+
deployments: [makeRunningDep({
|
|
197
|
+
workload_type: 'endpoint',
|
|
198
|
+
endpoint_url: 'https://dep-run-001.aibadgr.com/v1',
|
|
199
|
+
})],
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await statusCommand(config, [], chalk);
|
|
203
|
+
|
|
204
|
+
const allOutput = console.log.mock.calls.flat().join(' ');
|
|
205
|
+
expect(allOutput).toContain('dep-run-001.aibadgr.com');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('shows total billing rate when multiple deployments are running', async () => {
|
|
209
|
+
api.listDeployments.mockResolvedValue({
|
|
210
|
+
deployments: [
|
|
211
|
+
makeRunningDep({ deployment_id: 'dep-1', cost_per_hour: 1.20 }),
|
|
212
|
+
makeRunningDep({ deployment_id: 'dep-2', cost_per_hour: 2.50 }),
|
|
213
|
+
],
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await statusCommand(config, [], chalk);
|
|
217
|
+
|
|
218
|
+
const allOutput = console.log.mock.calls.flat().join(' ');
|
|
219
|
+
// Total: 1.20 + 2.50 = 3.70
|
|
220
|
+
expect(allOutput).toContain('3.70');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('shows provisioning-state deployment as starting', async () => {
|
|
224
|
+
api.listDeployments.mockResolvedValue({
|
|
225
|
+
deployments: [makeRunningDep({ status: 'provisioning', cost_per_hour: 0 })],
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await statusCommand(config, [], chalk);
|
|
229
|
+
|
|
230
|
+
const allOutput = console.log.mock.calls.flat().join(' ');
|
|
231
|
+
expect(allOutput).toContain('starting');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('falls back to local store when API is unavailable', async () => {
|
|
235
|
+
api.listDeployments.mockRejectedValue(new Error('connection refused'));
|
|
236
|
+
store.listDeployments.mockReturnValue([
|
|
237
|
+
{ id: 'dep-local-001', name: 'local-job', gpu: 'L40S', status: 'running', costPerHour: 1.10 },
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
await statusCommand(config, [], chalk);
|
|
241
|
+
|
|
242
|
+
const allOutput = console.log.mock.calls.flat().join(' ');
|
|
243
|
+
expect(allOutput).toContain('dep-local-001');
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
248
|
+
// 3. CUDA / compat failure error messages
|
|
249
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
describe('compat_failure error message', () => {
|
|
252
|
+
function makeCompatError() {
|
|
253
|
+
const err = new Error('PROVISIONING_FAILED');
|
|
254
|
+
err.errorData = {
|
|
255
|
+
code: 'PROVISIONING_FAILED',
|
|
256
|
+
failure_category: 'compat_failure',
|
|
257
|
+
};
|
|
258
|
+
return err;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
it('shows GPU/CUDA incompatibility message when failure_category is compat_failure', async () => {
|
|
262
|
+
api.callApi.mockRejectedValue(makeCompatError());
|
|
263
|
+
|
|
264
|
+
let thrown;
|
|
265
|
+
try {
|
|
266
|
+
await callWithFallback(
|
|
267
|
+
'/run',
|
|
268
|
+
{ apiKey: 'sk-test', baseUrl: 'https://api.test/v1' },
|
|
269
|
+
() => ({ gpu: 'RTX_4090' }),
|
|
270
|
+
'1',
|
|
271
|
+
chalk,
|
|
272
|
+
{ thing: 'job', cmd: 'badgr run' },
|
|
273
|
+
);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
thrown = err;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
expect(thrown).toBeInstanceOf(CapacityError);
|
|
279
|
+
expect(thrown.message).toContain('incompatib');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('shows generic provisioning failure when failure_category is infrastructure', async () => {
|
|
283
|
+
const err = new Error('PROVISIONING_FAILED');
|
|
284
|
+
err.errorData = { code: 'PROVISIONING_FAILED', failure_category: 'infrastructure' };
|
|
285
|
+
api.callApi.mockRejectedValue(err);
|
|
286
|
+
|
|
287
|
+
let thrown;
|
|
288
|
+
try {
|
|
289
|
+
await callWithFallback(
|
|
290
|
+
'/run',
|
|
291
|
+
{ apiKey: 'sk-test', baseUrl: 'https://api.test/v1' },
|
|
292
|
+
() => ({}),
|
|
293
|
+
'1',
|
|
294
|
+
chalk,
|
|
295
|
+
{ thing: 'job', cmd: 'badgr run' },
|
|
296
|
+
);
|
|
297
|
+
} catch (err2) {
|
|
298
|
+
thrown = err2;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
expect(thrown).toBeInstanceOf(CapacityError);
|
|
302
|
+
// Should NOT mention CUDA/compat
|
|
303
|
+
expect(thrown.message).not.toContain('incompatib');
|
|
304
|
+
expect(thrown.message).toContain('failed to start');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('compat message mentions trying a different GPU type or image', async () => {
|
|
308
|
+
api.callApi.mockRejectedValue(makeCompatError());
|
|
309
|
+
|
|
310
|
+
let thrown;
|
|
311
|
+
try {
|
|
312
|
+
await callWithFallback(
|
|
313
|
+
'/run',
|
|
314
|
+
{ apiKey: 'sk-test', baseUrl: 'https://api.test/v1' },
|
|
315
|
+
() => ({}),
|
|
316
|
+
'2',
|
|
317
|
+
chalk,
|
|
318
|
+
{ thing: 'job', cmd: 'badgr run' },
|
|
319
|
+
);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
thrown = err;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
expect(thrown.message).toMatch(/GPU type|image/i);
|
|
325
|
+
});
|
|
326
|
+
});
|
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
|
});
|