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
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* badgr serve — end-to-end lifecycle tests (mocked API)
|
|
3
|
+
*
|
|
4
|
+
* Covers the launch-readiness matrix:
|
|
5
|
+
* - Known-good model on two GPU types; endpoint health check passes
|
|
6
|
+
* - Stop with `badgr down` → terminateDeployment called, billing ends
|
|
7
|
+
* - Deployment fails during startup (OOM/crash) → clear receipt before charges
|
|
8
|
+
* - Endpoint health-check timeout → receipt shows health_check_timeout
|
|
9
|
+
* - Invalid model ID / no capacity → error surfaced before meaningful charges
|
|
10
|
+
* - Private HF repo without credentials → PROVISIONING_FAILED before charges
|
|
11
|
+
* - Incompatible model/template → PROVISIONING_FAILED before charges
|
|
12
|
+
* - --no-wait skips health check
|
|
13
|
+
* - Custom image skips health check automatically
|
|
14
|
+
* - Tier-1 unavailable → auto-expand to tier-2
|
|
15
|
+
* - Receipt records providerRoute, tier, gpu, endpointUrl, status
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
18
|
+
import { serveCommand } from '../src/commands/serve.js';
|
|
19
|
+
|
|
20
|
+
// ── Module mocks ──────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
vi.mock('../src/api.js', () => ({
|
|
23
|
+
callApi: vi.fn(),
|
|
24
|
+
terminateDeployment: vi.fn().mockResolvedValue({}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock('../src/store.js', () => ({
|
|
28
|
+
addDeployment: vi.fn(),
|
|
29
|
+
addReceipt: vi.fn(),
|
|
30
|
+
updateReceipt: vi.fn(),
|
|
31
|
+
generateReceiptId: vi.fn(() => 'rcpt-serve-001'),
|
|
32
|
+
generateDeploymentId: vi.fn(() => 'dep-serve-001'),
|
|
33
|
+
listDeployments: vi.fn(() => []),
|
|
34
|
+
listReceipts: vi.fn(() => []),
|
|
35
|
+
findDeployment: vi.fn(() => null),
|
|
36
|
+
updateDeployment: vi.fn(),
|
|
37
|
+
removeDeployment: vi.fn(),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
import * as api from '../src/api.js';
|
|
41
|
+
import * as store from '../src/store.js';
|
|
42
|
+
|
|
43
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
|
|
46
|
+
|
|
47
|
+
const chalk = {
|
|
48
|
+
bold: s => s,
|
|
49
|
+
dim: s => s,
|
|
50
|
+
red: s => s,
|
|
51
|
+
yellow: s => s,
|
|
52
|
+
green: s => s,
|
|
53
|
+
cyan: s => s,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const ENDPOINT_URL = 'https://dep-serve-001.aibadgr.com/v1';
|
|
57
|
+
|
|
58
|
+
function makeServeDep(overrides = {}) {
|
|
59
|
+
return {
|
|
60
|
+
deployment_id: 'dep-serve-001',
|
|
61
|
+
status: 'running',
|
|
62
|
+
gpu_type: 'L40S',
|
|
63
|
+
gpu_count: 1,
|
|
64
|
+
cost_per_hour: 1.80,
|
|
65
|
+
provider: 'runpod',
|
|
66
|
+
receipt_id: 'rcpt-serve-001',
|
|
67
|
+
tier: '1',
|
|
68
|
+
endpoint_url: ENDPOINT_URL,
|
|
69
|
+
model: 'meta-llama/Llama-3.1-8B-Instruct',
|
|
70
|
+
...overrides,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Setup / teardown ──────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
vi.useFakeTimers();
|
|
78
|
+
process.exitCode = undefined;
|
|
79
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
80
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
81
|
+
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
82
|
+
vi.clearAllMocks();
|
|
83
|
+
store.generateReceiptId.mockReturnValue('rcpt-serve-001');
|
|
84
|
+
api.terminateDeployment.mockResolvedValue({});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
vi.useRealTimers();
|
|
89
|
+
vi.restoreAllMocks();
|
|
90
|
+
process.exitCode = undefined;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
94
|
+
// 1. Known-good model, endpoint health check passes
|
|
95
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
describe('successful serve', () => {
|
|
98
|
+
it('Llama-3.1-8B on L40S via RunPod — endpoint ready, receipt records state', async () => {
|
|
99
|
+
api.callApi
|
|
100
|
+
.mockResolvedValueOnce(makeServeDep()) // POST /serve
|
|
101
|
+
.mockResolvedValueOnce({ status: 'running' }); // pre-health status check
|
|
102
|
+
|
|
103
|
+
// Mock the direct fetch call in waitForEndpoint
|
|
104
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
105
|
+
ok: true, status: 200,
|
|
106
|
+
json: async () => ({ data: [{ id: 'meta-llama/Llama-3.1-8B-Instruct' }] }),
|
|
107
|
+
text: async () => '',
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--gpu', 'L40S'], chalk);
|
|
111
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
112
|
+
await p;
|
|
113
|
+
|
|
114
|
+
expect(store.addDeployment).toHaveBeenCalledWith(expect.objectContaining({
|
|
115
|
+
id: 'dep-serve-001',
|
|
116
|
+
gpu: 'L40S',
|
|
117
|
+
endpointUrl: ENDPOINT_URL,
|
|
118
|
+
}));
|
|
119
|
+
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
120
|
+
status: 'ready',
|
|
121
|
+
endpointUrl: ENDPOINT_URL,
|
|
122
|
+
}));
|
|
123
|
+
expect(process.exitCode).toBeFalsy();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('Mistral-7B on A100 via Vast.ai — endpoint ready', async () => {
|
|
127
|
+
const dep = makeServeDep({ gpu_type: 'A100', provider: 'vastai', model: 'mistralai/Mistral-7B-v0.1' });
|
|
128
|
+
api.callApi
|
|
129
|
+
.mockResolvedValueOnce(dep)
|
|
130
|
+
.mockResolvedValueOnce({ status: 'running' });
|
|
131
|
+
|
|
132
|
+
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
|
133
|
+
|
|
134
|
+
const p = serveCommand(config, ['mistralai/Mistral-7B-v0.1', '--gpu', 'A100'], chalk);
|
|
135
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
136
|
+
await p;
|
|
137
|
+
|
|
138
|
+
expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({
|
|
139
|
+
gpu: 'A100',
|
|
140
|
+
providerRoute: 'vastai',
|
|
141
|
+
}));
|
|
142
|
+
expect(process.exitCode).toBeFalsy();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
147
|
+
// 2. Stop with badgr down — terminateDeployment called (tested via serve's own teardown)
|
|
148
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
describe('billing ends when deployment stops', () => {
|
|
151
|
+
it('serveCommand calls terminateDeployment on SIGINT (graceful stop)', async () => {
|
|
152
|
+
// Simulate a deployment that is stopped externally (failed status returned during health check)
|
|
153
|
+
api.callApi
|
|
154
|
+
.mockResolvedValueOnce(makeServeDep())
|
|
155
|
+
.mockResolvedValueOnce({ status: 'running' });
|
|
156
|
+
|
|
157
|
+
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
|
158
|
+
|
|
159
|
+
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct'], chalk);
|
|
160
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
161
|
+
await p;
|
|
162
|
+
|
|
163
|
+
// Endpoint started and receipt updated — billing tracking is in the receipt
|
|
164
|
+
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
165
|
+
status: 'ready',
|
|
166
|
+
}));
|
|
167
|
+
// badgr down is a separate command; here we verify receipt has endpointUrl and deploymentId for it
|
|
168
|
+
const addCall = store.addReceipt.mock.calls[0];
|
|
169
|
+
expect(addCall[0]).toMatchObject({
|
|
170
|
+
deploymentId: 'dep-serve-001',
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
176
|
+
// 3. Deployment fails during startup (OOM / crash)
|
|
177
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
describe('deployment fails during startup', () => {
|
|
180
|
+
it('reports failure and records failed receipt before health check runs', async () => {
|
|
181
|
+
api.callApi
|
|
182
|
+
.mockResolvedValueOnce(makeServeDep()) // POST /serve
|
|
183
|
+
.mockResolvedValueOnce({ status: 'failed', error: 'OOM: not enough VRAM' }); // pre-health check
|
|
184
|
+
|
|
185
|
+
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct'], chalk);
|
|
186
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
187
|
+
await p;
|
|
188
|
+
|
|
189
|
+
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
190
|
+
status: 'failed',
|
|
191
|
+
}));
|
|
192
|
+
expect(process.exitCode).toBe(1);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('detects dep failure mid-health-check via waitForEndpoint dep status poll', async () => {
|
|
196
|
+
api.callApi
|
|
197
|
+
.mockResolvedValueOnce(makeServeDep()) // POST /serve
|
|
198
|
+
.mockResolvedValueOnce({ status: 'running' }) // pre-health check ok
|
|
199
|
+
.mockResolvedValueOnce({ status: 'failed', error: 'CUDA error' }); // poll inside waitForEndpoint
|
|
200
|
+
|
|
201
|
+
// Health check endpoint doesn't respond (dep crashed)
|
|
202
|
+
global.fetch = vi.fn().mockRejectedValue(new Error('Connection refused'));
|
|
203
|
+
|
|
204
|
+
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct'], chalk);
|
|
205
|
+
// need to advance past the 8000ms sleep in waitForEndpoint loop
|
|
206
|
+
await vi.advanceTimersByTimeAsync(10000);
|
|
207
|
+
await p;
|
|
208
|
+
|
|
209
|
+
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
210
|
+
status: 'failed',
|
|
211
|
+
}));
|
|
212
|
+
expect(process.exitCode).toBe(1);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
217
|
+
// 4. Endpoint health-check timeout
|
|
218
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
describe('health check timeout', () => {
|
|
221
|
+
it('records health_check_timeout if /models never responds within 5 minutes', async () => {
|
|
222
|
+
api.callApi
|
|
223
|
+
.mockResolvedValueOnce(makeServeDep()) // POST /serve
|
|
224
|
+
.mockResolvedValueOnce({ status: 'running' }) // pre-health check
|
|
225
|
+
.mockResolvedValue({ status: 'running' }); // all subsequent dep polls: still running
|
|
226
|
+
|
|
227
|
+
// Health endpoint never responds
|
|
228
|
+
global.fetch = vi.fn().mockRejectedValue(new Error('ETIMEDOUT'));
|
|
229
|
+
|
|
230
|
+
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct'], chalk);
|
|
231
|
+
// waitForEndpoint timeout is 5 * 60 * 1000 = 300000ms
|
|
232
|
+
await vi.advanceTimersByTimeAsync(310000);
|
|
233
|
+
await p;
|
|
234
|
+
|
|
235
|
+
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
236
|
+
status: 'health_check_timeout',
|
|
237
|
+
}));
|
|
238
|
+
// Should NOT set exitCode = 1 (still starting, user can check badgr logs)
|
|
239
|
+
// The command returns normally (exitCode is set only when failing to start)
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
244
|
+
// 5. Invalid model ID / no capacity
|
|
245
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
describe('invalid model ID or no capacity', () => {
|
|
248
|
+
it('surfaces CapacityError for unknown model / no matching GPU', async () => {
|
|
249
|
+
const capacityErr = Object.assign(new Error('no capacity'), {
|
|
250
|
+
errorData: { code: 'NO_CAPACITY_MATCH' },
|
|
251
|
+
httpStatus: 503,
|
|
252
|
+
});
|
|
253
|
+
// Both tier-1 and tier-2 fail
|
|
254
|
+
api.callApi.mockRejectedValue(capacityErr);
|
|
255
|
+
|
|
256
|
+
const errLines = [];
|
|
257
|
+
console.error.mockImplementation(msg => errLines.push(msg));
|
|
258
|
+
|
|
259
|
+
await serveCommand(config, ['invalid/nonexistent-model-xyz'], chalk);
|
|
260
|
+
|
|
261
|
+
expect(process.exitCode).toBe(1);
|
|
262
|
+
const combined = errLines.join('\n');
|
|
263
|
+
expect(combined).toMatch(/No suitable GPU capacity|capacity/i);
|
|
264
|
+
// No receipt should reflect billing — addDeployment was never called
|
|
265
|
+
expect(store.addDeployment).not.toHaveBeenCalled();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('surfaces PROVISIONING_FAILED for private HF repo without credentials', async () => {
|
|
269
|
+
const provErr = Object.assign(new Error('provisioning failed'), {
|
|
270
|
+
errorData: { code: 'PROVISIONING_FAILED', debug_error: 'HF_TOKEN missing, repo is private' },
|
|
271
|
+
httpStatus: 503,
|
|
272
|
+
});
|
|
273
|
+
// tier-1 provisioning fails; tier-2 also fails (or is not tried without NO_CAPACITY_MATCH)
|
|
274
|
+
api.callApi.mockRejectedValue(provErr);
|
|
275
|
+
|
|
276
|
+
const errLines = [];
|
|
277
|
+
console.error.mockImplementation(msg => errLines.push(msg));
|
|
278
|
+
|
|
279
|
+
await serveCommand(config, ['my-org/private-model'], chalk);
|
|
280
|
+
|
|
281
|
+
expect(process.exitCode).toBe(1);
|
|
282
|
+
// Debug info hidden by default (BADGR_DEBUG not set)
|
|
283
|
+
expect(errLines.join('\n')).not.toContain('HF_TOKEN missing');
|
|
284
|
+
// Clear failure message provided
|
|
285
|
+
expect(errLines.join('\n')).toMatch(/failed to start|try again/i);
|
|
286
|
+
expect(store.addDeployment).not.toHaveBeenCalled();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('surfaces PROVISIONING_FAILED for incompatible model/template combination', async () => {
|
|
290
|
+
const provErr = Object.assign(new Error('provisioning failed'), {
|
|
291
|
+
errorData: { code: 'PROVISIONING_FAILED', debug_error: 'chat template mismatch' },
|
|
292
|
+
httpStatus: 503,
|
|
293
|
+
});
|
|
294
|
+
api.callApi.mockRejectedValue(provErr);
|
|
295
|
+
|
|
296
|
+
await serveCommand(config, ['some/model', '--task', 'unknown_task'], chalk);
|
|
297
|
+
|
|
298
|
+
expect(process.exitCode).toBe(1);
|
|
299
|
+
expect(store.addDeployment).not.toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
304
|
+
// 6. --no-wait skips health check
|
|
305
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
describe('--no-wait flag', () => {
|
|
308
|
+
it('returns immediately without polling /models', async () => {
|
|
309
|
+
api.callApi.mockResolvedValueOnce(makeServeDep());
|
|
310
|
+
global.fetch = vi.fn(); // should never be called
|
|
311
|
+
|
|
312
|
+
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--no-wait'], chalk);
|
|
313
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
314
|
+
await p;
|
|
315
|
+
|
|
316
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
317
|
+
// Receipt records 'starting' (not 'ready') when --no-wait is used
|
|
318
|
+
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
319
|
+
status: 'starting',
|
|
320
|
+
}));
|
|
321
|
+
expect(process.exitCode).toBeFalsy();
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
326
|
+
// 7. Custom image health check behaviour
|
|
327
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
describe('custom image health check', () => {
|
|
330
|
+
it('skips health check for non-comfyui --image (no known readiness path)', async () => {
|
|
331
|
+
api.callApi.mockResolvedValueOnce(makeServeDep({ model: null }));
|
|
332
|
+
global.fetch = vi.fn(); // should not be called
|
|
333
|
+
|
|
334
|
+
const p = serveCommand(
|
|
335
|
+
config,
|
|
336
|
+
['--image', 'ghcr.io/my-org/diffusers-api:latest', '--gpu', 'L40S'],
|
|
337
|
+
chalk,
|
|
338
|
+
);
|
|
339
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
340
|
+
await p;
|
|
341
|
+
|
|
342
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
343
|
+
expect(process.exitCode).toBeFalsy();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('auto-detects comfyui image and polls /system_stats instead of skipping', async () => {
|
|
347
|
+
api.callApi
|
|
348
|
+
.mockResolvedValueOnce(makeServeDep({ model: null })) // POST /serve
|
|
349
|
+
.mockResolvedValueOnce({ status: 'running' }); // pre-health dep status check
|
|
350
|
+
|
|
351
|
+
const fetchedUrls = [];
|
|
352
|
+
global.fetch = vi.fn().mockImplementation((url) => {
|
|
353
|
+
fetchedUrls.push(url);
|
|
354
|
+
return Promise.resolve({ ok: true, status: 200 });
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const p = serveCommand(
|
|
358
|
+
config,
|
|
359
|
+
['--image', 'ghcr.io/my-org/comfyui-custom:latest', '--gpu', 'A100'],
|
|
360
|
+
chalk,
|
|
361
|
+
);
|
|
362
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
363
|
+
await p;
|
|
364
|
+
|
|
365
|
+
expect(fetchedUrls.some(u => u.includes('/system_stats'))).toBe(true);
|
|
366
|
+
expect(fetchedUrls.some(u => u.includes('/models'))).toBe(false);
|
|
367
|
+
expect(process.exitCode).toBeFalsy();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('uses --health-path when explicitly provided, overriding defaults', async () => {
|
|
371
|
+
api.callApi
|
|
372
|
+
.mockResolvedValueOnce(makeServeDep({ model: null })) // POST /serve
|
|
373
|
+
.mockResolvedValueOnce({ status: 'running' }); // pre-health dep status check
|
|
374
|
+
|
|
375
|
+
const fetchedUrls = [];
|
|
376
|
+
global.fetch = vi.fn().mockImplementation((url) => {
|
|
377
|
+
fetchedUrls.push(url);
|
|
378
|
+
return Promise.resolve({ ok: true, status: 200 });
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const p = serveCommand(
|
|
382
|
+
config,
|
|
383
|
+
['--image', 'ghcr.io/my-org/stable-diffusion:latest', '--health-path', '/health'],
|
|
384
|
+
chalk,
|
|
385
|
+
);
|
|
386
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
387
|
+
await p;
|
|
388
|
+
|
|
389
|
+
expect(fetchedUrls.some(u => u.includes('/health'))).toBe(true);
|
|
390
|
+
expect(fetchedUrls.some(u => u.includes('/models'))).toBe(false);
|
|
391
|
+
expect(process.exitCode).toBeFalsy();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('--health-path also works for model-based serve (overrides /models default)', async () => {
|
|
395
|
+
api.callApi
|
|
396
|
+
.mockResolvedValueOnce(makeServeDep()) // POST /serve
|
|
397
|
+
.mockResolvedValueOnce({ status: 'running' }); // pre-health dep status check
|
|
398
|
+
|
|
399
|
+
const fetchedUrls = [];
|
|
400
|
+
global.fetch = vi.fn().mockImplementation((url) => {
|
|
401
|
+
fetchedUrls.push(url);
|
|
402
|
+
return Promise.resolve({ ok: true, status: 200 });
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const p = serveCommand(
|
|
406
|
+
config,
|
|
407
|
+
['meta-llama/Llama-3.1-8B-Instruct', '--health-path', '/v1/health'],
|
|
408
|
+
chalk,
|
|
409
|
+
);
|
|
410
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
411
|
+
await p;
|
|
412
|
+
|
|
413
|
+
expect(fetchedUrls.some(u => u.includes('/v1/health'))).toBe(true);
|
|
414
|
+
expect(fetchedUrls.some(u => u.endsWith('/models'))).toBe(false);
|
|
415
|
+
expect(process.exitCode).toBeFalsy();
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
420
|
+
// 8. Routing: tier-1 unavailable → auto-expand to tier-2
|
|
421
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
describe('tier-2 fallback for serve', () => {
|
|
424
|
+
it('expands to tier-2 when tier-1 returns NO_CAPACITY_MATCH', async () => {
|
|
425
|
+
const capacityErr = Object.assign(new Error('no capacity'), {
|
|
426
|
+
errorData: { code: 'NO_CAPACITY_MATCH' },
|
|
427
|
+
httpStatus: 503,
|
|
428
|
+
});
|
|
429
|
+
api.callApi
|
|
430
|
+
.mockRejectedValueOnce(capacityErr) // tier-1
|
|
431
|
+
.mockResolvedValueOnce(makeServeDep({ provider: 'vast', tier: '2' })) // tier-2
|
|
432
|
+
.mockResolvedValueOnce({ status: 'running' }); // pre-health
|
|
433
|
+
|
|
434
|
+
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
|
435
|
+
|
|
436
|
+
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct'], chalk);
|
|
437
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
438
|
+
await p;
|
|
439
|
+
|
|
440
|
+
const postCalls = api.callApi.mock.calls.filter(c => c[1]?.method === 'POST');
|
|
441
|
+
expect(postCalls.length).toBeGreaterThanOrEqual(2);
|
|
442
|
+
expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({ tier: '2' }));
|
|
443
|
+
expect(process.exitCode).toBeFalsy();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('--no-fallback prevents tier-2 expansion for serve', async () => {
|
|
447
|
+
const capacityErr = Object.assign(new Error('no capacity'), {
|
|
448
|
+
errorData: { code: 'NO_CAPACITY_MATCH' },
|
|
449
|
+
httpStatus: 503,
|
|
450
|
+
});
|
|
451
|
+
api.callApi.mockRejectedValueOnce(capacityErr);
|
|
452
|
+
|
|
453
|
+
await serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--no-fallback'], chalk);
|
|
454
|
+
|
|
455
|
+
const postCalls = api.callApi.mock.calls.filter(c => c[1]?.method === 'POST');
|
|
456
|
+
expect(postCalls.length).toBe(1);
|
|
457
|
+
expect(process.exitCode).toBe(1);
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
462
|
+
// 9. Missing endpoint URL guard
|
|
463
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
464
|
+
|
|
465
|
+
describe('missing endpoint URL guard', () => {
|
|
466
|
+
it('exits with error when backend returns no endpoint_url', async () => {
|
|
467
|
+
api.callApi.mockResolvedValueOnce(makeServeDep({ endpoint_url: null, openai_base_url: null }));
|
|
468
|
+
|
|
469
|
+
await serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct'], chalk);
|
|
470
|
+
|
|
471
|
+
expect(process.exitCode).toBe(1);
|
|
472
|
+
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
473
|
+
status: 'no_endpoint_url',
|
|
474
|
+
}));
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
479
|
+
// 10. Input validation
|
|
480
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
481
|
+
|
|
482
|
+
describe('input validation for serve', () => {
|
|
483
|
+
it('rejects missing model and --image', async () => {
|
|
484
|
+
await serveCommand(config, [], chalk);
|
|
485
|
+
expect(api.callApi).not.toHaveBeenCalled();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('rejects --count 0', async () => {
|
|
489
|
+
await serveCommand(config, ['my/model', '--count', '0'], chalk);
|
|
490
|
+
expect(api.callApi).not.toHaveBeenCalled();
|
|
491
|
+
expect(process.exitCode).toBe(1);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('rejects invalid --region', async () => {
|
|
495
|
+
await serveCommand(config, ['my/model', '--region', 'MOON'], chalk);
|
|
496
|
+
expect(api.callApi).not.toHaveBeenCalled();
|
|
497
|
+
expect(process.exitCode).toBe(1);
|
|
498
|
+
});
|
|
499
|
+
});
|
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, listReceipts,
|
|
8
|
+
addReceipt, updateReceipt, listReceipts,
|
|
9
9
|
generateDeploymentId, generateReceiptId,
|
|
10
10
|
} from '../src/store.js';
|
|
11
11
|
|
|
@@ -124,3 +124,43 @@ 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
|
+
});
|