badgr-cli 1.1.0 → 1.1.2
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/LICENSE +207 -0
- package/README.md +135 -3
- package/package.json +44 -2
- package/src/api.js +16 -0
- package/src/badgr.js +2 -2
- package/src/commands/batch.js +11 -0
- package/src/commands/comfyui.js +31 -15
- package/src/commands/embed.js +13 -10
- package/src/commands/launch.js +8 -1
- package/src/commands/login.js +75 -20
- package/src/commands/run.js +45 -13
- package/src/commands/sbatch.js +6 -1
- package/src/commands/serve.js +44 -30
- package/src/commands/train.js +8 -12
- package/src/commands/transcribe.js +13 -10
- package/src/envFlag.js +10 -0
- package/src/onboarding.js +8 -1
- package/src/progress.js +48 -0
- package/tests/agent-images.test.js +0 -17
- package/tests/api.test.js +0 -168
- package/tests/artifactDownload.test.js +0 -113
- package/tests/artifacts.test.js +0 -168
- package/tests/batch.test.js +0 -641
- package/tests/browser.test.js +0 -51
- package/tests/capacity.test.js +0 -68
- package/tests/commands.test.js +0 -417
- package/tests/config.test.js +0 -96
- package/tests/connect.test.js +0 -83
- package/tests/detect.test.js +0 -191
- package/tests/down.test.js +0 -150
- package/tests/errors.test.js +0 -130
- package/tests/fallback-timeout.test.js +0 -41
- package/tests/fanout.test.js +0 -124
- package/tests/gpu-doctor-classifiers.test.js +0 -402
- package/tests/gpu-doctor-doctor.test.js +0 -304
- package/tests/gpu-doctor-probe-cache.test.js +0 -110
- package/tests/gpu-doctor-probes.test.js +0 -257
- package/tests/heartbeat.test.js +0 -70
- package/tests/job-progress-poll.test.js +0 -136
- package/tests/launch-command-argv.test.js +0 -93
- package/tests/launch-readiness.test.js +0 -403
- package/tests/launch.test.js +0 -440
- package/tests/onboarding.test.js +0 -134
- package/tests/productized-dry-run.test.js +0 -141
- package/tests/productized-runners.test.js +0 -237
- package/tests/pull.test.js +0 -266
- package/tests/rerun.test.js +0 -94
- package/tests/restart.test.js +0 -88
- package/tests/router.test.js +0 -98
- package/tests/run-lifecycle.test.js +0 -1054
- package/tests/sbatch.test.js +0 -190
- package/tests/secrets.test.js +0 -16
- package/tests/serve-apps.test.js +0 -189
- package/tests/serve-lifecycle.test.js +0 -931
- package/tests/slurm.test.js +0 -77
- package/tests/spec.test.js +0 -201
- package/tests/status.test.js +0 -73
- package/tests/store.test.js +0 -187
- package/tests/task.test.js +0 -109
- package/tests/template.test.js +0 -556
- package/tests/train-lora-dataset.test.js +0 -176
- package/tests/upload.test.js +0 -79
- package/tests/workload-rerun.test.js +0 -56
- package/tests/workload-spec.test.js +0 -180
- package/tests/workload-templates.test.js +0 -865
- package/tests/workload-workspace-paths.test.js +0 -46
|
@@ -1,931 +0,0 @@
|
|
|
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
|
-
* Readiness is driven entirely by Badgr's own deployment status
|
|
18
|
-
* (GET /deployments/{id} — endpoint_ready/health_path/readiness_reason/fix_hint).
|
|
19
|
-
* The CLI never fetches the RunPod proxy/pod endpoint directly, so these tests
|
|
20
|
-
* mock api.callApi responses rather than global.fetch.
|
|
21
|
-
*/
|
|
22
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
23
|
-
import { serveCommand } from '../src/commands/serve.js';
|
|
24
|
-
|
|
25
|
-
// ── Module mocks ──────────────────────────────────────────────────────────────
|
|
26
|
-
|
|
27
|
-
vi.mock('../src/api.js', () => ({
|
|
28
|
-
callApi: vi.fn(),
|
|
29
|
-
terminateDeployment: vi.fn().mockResolvedValue({}),
|
|
30
|
-
listDeployments: vi.fn().mockResolvedValue({ deployments: [], count: 0 }),
|
|
31
|
-
}));
|
|
32
|
-
|
|
33
|
-
vi.mock('../src/store.js', () => ({
|
|
34
|
-
addDeployment: vi.fn(),
|
|
35
|
-
addReceipt: vi.fn(),
|
|
36
|
-
updateReceipt: vi.fn(),
|
|
37
|
-
generateReceiptId: vi.fn(() => 'rcpt-serve-001'),
|
|
38
|
-
generateDeploymentId: vi.fn(() => 'dep-serve-001'),
|
|
39
|
-
listDeployments: vi.fn(() => []),
|
|
40
|
-
listReceipts: vi.fn(() => []),
|
|
41
|
-
findDeployment: vi.fn(() => null),
|
|
42
|
-
updateDeployment: vi.fn(),
|
|
43
|
-
removeDeployment: vi.fn(),
|
|
44
|
-
}));
|
|
45
|
-
|
|
46
|
-
import * as api from '../src/api.js';
|
|
47
|
-
import * as store from '../src/store.js';
|
|
48
|
-
|
|
49
|
-
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
50
|
-
|
|
51
|
-
const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
|
|
52
|
-
|
|
53
|
-
const chalk = {
|
|
54
|
-
bold: s => s,
|
|
55
|
-
dim: s => s,
|
|
56
|
-
red: s => s,
|
|
57
|
-
yellow: s => s,
|
|
58
|
-
green: s => s,
|
|
59
|
-
cyan: s => s,
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const ENDPOINT_URL = 'https://dep-serve-001.aibadgr.com/v1';
|
|
63
|
-
|
|
64
|
-
function makeServeDep(overrides = {}) {
|
|
65
|
-
return {
|
|
66
|
-
deployment_id: 'dep-serve-001',
|
|
67
|
-
status: 'running',
|
|
68
|
-
gpu_type: 'L40S',
|
|
69
|
-
gpu_count: 1,
|
|
70
|
-
cost_per_hour: 1.80,
|
|
71
|
-
provider: 'runpod',
|
|
72
|
-
receipt_id: 'rcpt-serve-001',
|
|
73
|
-
tier: '1',
|
|
74
|
-
endpoint_url: ENDPOINT_URL,
|
|
75
|
-
model: 'meta-llama/Llama-3.1-8B-Instruct',
|
|
76
|
-
...overrides,
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// A GET /deployments/{id} response where the backend reports the app-level
|
|
81
|
-
// endpoint is ready (endpoint_ready:true) — this is what the CLI now waits on
|
|
82
|
-
// instead of fetching the pod directly.
|
|
83
|
-
function readyStatus(overrides = {}) {
|
|
84
|
-
return { status: 'running', endpoint_ready: true, health_path: '/v1/models', ...overrides };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function notReadyStatus(overrides = {}) {
|
|
88
|
-
return { status: 'running', endpoint_ready: false, health_path: '/v1/models', readiness_reason: 'starting', ...overrides };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ── Setup / teardown ──────────────────────────────────────────────────────────
|
|
92
|
-
|
|
93
|
-
beforeEach(() => {
|
|
94
|
-
vi.useFakeTimers();
|
|
95
|
-
process.exitCode = undefined;
|
|
96
|
-
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
97
|
-
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
98
|
-
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
99
|
-
vi.clearAllMocks();
|
|
100
|
-
store.generateReceiptId.mockReturnValue('rcpt-serve-001');
|
|
101
|
-
api.terminateDeployment.mockResolvedValue({});
|
|
102
|
-
// No test should ever hit the network directly — waitForEndpoint polls
|
|
103
|
-
// Badgr's own API only. Fail loudly if anything calls fetch.
|
|
104
|
-
global.fetch = vi.fn().mockRejectedValue(new Error('serve.js must not call fetch() directly'));
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
afterEach(() => {
|
|
108
|
-
vi.useRealTimers();
|
|
109
|
-
vi.restoreAllMocks();
|
|
110
|
-
process.exitCode = undefined;
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
114
|
-
// 1. Known-good model, endpoint health check passes
|
|
115
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
116
|
-
|
|
117
|
-
describe('successful serve', () => {
|
|
118
|
-
it('Llama-3.1-8B on L40S via RunPod — endpoint ready, receipt records state', async () => {
|
|
119
|
-
api.callApi
|
|
120
|
-
.mockResolvedValueOnce(makeServeDep()) // POST /serve
|
|
121
|
-
.mockResolvedValueOnce({ status: 'running' }) // pre-health status check
|
|
122
|
-
.mockResolvedValueOnce(readyStatus()); // waitForEndpoint poll
|
|
123
|
-
|
|
124
|
-
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--gpu', 'L40S', '--max-cost', '5'], chalk);
|
|
125
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
126
|
-
await p;
|
|
127
|
-
|
|
128
|
-
expect(store.addDeployment).toHaveBeenCalledWith(expect.objectContaining({
|
|
129
|
-
id: 'dep-serve-001',
|
|
130
|
-
gpu: 'L40S',
|
|
131
|
-
endpointUrl: ENDPOINT_URL,
|
|
132
|
-
}));
|
|
133
|
-
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
134
|
-
status: 'ready',
|
|
135
|
-
endpointUrl: ENDPOINT_URL,
|
|
136
|
-
}));
|
|
137
|
-
expect(global.fetch).not.toHaveBeenCalled();
|
|
138
|
-
expect(process.exitCode).toBeFalsy();
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('Mistral-7B on A100 via Vast.ai — endpoint ready', async () => {
|
|
142
|
-
const dep = makeServeDep({ gpu_type: 'A100', provider: 'vastai', model: 'mistralai/Mistral-7B-v0.1' });
|
|
143
|
-
api.callApi
|
|
144
|
-
.mockResolvedValueOnce(dep)
|
|
145
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
146
|
-
.mockResolvedValueOnce(readyStatus());
|
|
147
|
-
|
|
148
|
-
const p = serveCommand(config, ['mistralai/Mistral-7B-v0.1', '--gpu', 'A100', '--max-cost', '5'], chalk);
|
|
149
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
150
|
-
await p;
|
|
151
|
-
|
|
152
|
-
expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({
|
|
153
|
-
gpu: 'A100',
|
|
154
|
-
providerRoute: 'vastai',
|
|
155
|
-
}));
|
|
156
|
-
expect(process.exitCode).toBeFalsy();
|
|
157
|
-
});
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
161
|
-
// 2. Stop with badgr down — terminateDeployment called (tested via serve's own teardown)
|
|
162
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
163
|
-
|
|
164
|
-
describe('billing ends when deployment stops', () => {
|
|
165
|
-
it('serveCommand calls terminateDeployment on SIGINT (graceful stop)', async () => {
|
|
166
|
-
api.callApi
|
|
167
|
-
.mockResolvedValueOnce(makeServeDep())
|
|
168
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
169
|
-
.mockResolvedValueOnce(readyStatus());
|
|
170
|
-
|
|
171
|
-
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
|
|
172
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
173
|
-
await p;
|
|
174
|
-
|
|
175
|
-
// Endpoint started and receipt updated — billing tracking is in the receipt
|
|
176
|
-
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
177
|
-
status: 'ready',
|
|
178
|
-
}));
|
|
179
|
-
// badgr down is a separate command; here we verify receipt has endpointUrl and deploymentId for it
|
|
180
|
-
const addCall = store.addReceipt.mock.calls[0];
|
|
181
|
-
expect(addCall[0]).toMatchObject({
|
|
182
|
-
deploymentId: 'dep-serve-001',
|
|
183
|
-
});
|
|
184
|
-
});
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
188
|
-
// 3. Deployment fails during startup (OOM / crash)
|
|
189
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
190
|
-
|
|
191
|
-
describe('deployment fails during startup', () => {
|
|
192
|
-
it('reports failure and records failed receipt before health check runs', async () => {
|
|
193
|
-
api.callApi
|
|
194
|
-
.mockResolvedValueOnce(makeServeDep()) // POST /serve
|
|
195
|
-
.mockResolvedValueOnce({ status: 'failed', error: 'OOM: not enough VRAM' }); // pre-health check
|
|
196
|
-
|
|
197
|
-
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
|
|
198
|
-
await vi.advanceTimersByTimeAsync(2000);
|
|
199
|
-
await p;
|
|
200
|
-
|
|
201
|
-
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
202
|
-
status: 'failed',
|
|
203
|
-
}));
|
|
204
|
-
expect(process.exitCode).toBe(1);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it('detects dep failure mid-health-check via waitForEndpoint dep status poll', async () => {
|
|
208
|
-
api.callApi
|
|
209
|
-
.mockResolvedValueOnce(makeServeDep()) // POST /serve
|
|
210
|
-
.mockResolvedValueOnce({ status: 'running' }) // pre-health check ok
|
|
211
|
-
.mockResolvedValueOnce({ status: 'failed', error: 'CUDA error' }); // poll inside waitForEndpoint
|
|
212
|
-
|
|
213
|
-
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
|
|
214
|
-
// need to advance past the 8000ms sleep in waitForEndpoint loop
|
|
215
|
-
await vi.advanceTimersByTimeAsync(10000);
|
|
216
|
-
await p;
|
|
217
|
-
|
|
218
|
-
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
219
|
-
status: 'failed',
|
|
220
|
-
}));
|
|
221
|
-
expect(process.exitCode).toBe(1);
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it('surfaces backend fix_hint as the failure reason (pod exited / readiness timeout)', async () => {
|
|
225
|
-
api.callApi
|
|
226
|
-
.mockResolvedValueOnce(makeServeDep())
|
|
227
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
228
|
-
.mockResolvedValueOnce({
|
|
229
|
-
status: 'failed',
|
|
230
|
-
error: 'pod_exited',
|
|
231
|
-
fix_hint: 'The pod stopped running before the endpoint ever responded — likely an OOM.',
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
|
|
235
|
-
await vi.advanceTimersByTimeAsync(10000);
|
|
236
|
-
await p;
|
|
237
|
-
|
|
238
|
-
expect(process.exitCode).toBe(1);
|
|
239
|
-
const errLines = console.error.mock.calls.flat().join('\n');
|
|
240
|
-
expect(errLines).toContain('likely an OOM');
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
it('prints failure_class/next_action from the backend using the shared vocabulary', async () => {
|
|
244
|
-
api.callApi
|
|
245
|
-
.mockResolvedValueOnce(makeServeDep())
|
|
246
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
247
|
-
.mockResolvedValueOnce({
|
|
248
|
-
status: 'failed',
|
|
249
|
-
error: 'pod_exited',
|
|
250
|
-
fix_hint: 'The pod stopped running before the endpoint ever responded.',
|
|
251
|
-
failure_class: 'container_start_failed',
|
|
252
|
-
next_action: 'Check the image and command, then retry.',
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
|
|
256
|
-
await vi.advanceTimersByTimeAsync(10000);
|
|
257
|
-
await p;
|
|
258
|
-
|
|
259
|
-
const errLines = console.error.mock.calls.flat().join('\n');
|
|
260
|
-
expect(errLines).toContain('Class: container_start_failed');
|
|
261
|
-
expect(errLines).toContain('Next: Check the image and command, then retry.');
|
|
262
|
-
});
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
266
|
-
// 4. Endpoint health-check timeout
|
|
267
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
268
|
-
|
|
269
|
-
describe('health check timeout', () => {
|
|
270
|
-
it('records health_check_timeout if endpoint_ready never becomes true within 15 minutes', async () => {
|
|
271
|
-
api.callApi
|
|
272
|
-
.mockResolvedValueOnce(makeServeDep()) // POST /serve
|
|
273
|
-
.mockResolvedValueOnce({ status: 'running' }) // pre-health check
|
|
274
|
-
.mockResolvedValue(notReadyStatus()); // all subsequent dep polls: still not ready
|
|
275
|
-
|
|
276
|
-
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
|
|
277
|
-
// waitForEndpoint timeout is VLLM_SERVE_WAIT_MS = 15 * 60 * 1000
|
|
278
|
-
await vi.advanceTimersByTimeAsync(910000);
|
|
279
|
-
await p;
|
|
280
|
-
|
|
281
|
-
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
282
|
-
status: 'health_check_timeout',
|
|
283
|
-
}));
|
|
284
|
-
// Should NOT set exitCode = 1 (still starting, user can check badgr logs)
|
|
285
|
-
// The command returns normally (exitCode is set only when failing to start)
|
|
286
|
-
});
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
290
|
-
// 5. Invalid model ID / no capacity
|
|
291
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
292
|
-
|
|
293
|
-
describe('invalid model ID or no capacity', () => {
|
|
294
|
-
it('surfaces CapacityError for unknown model / no matching GPU', async () => {
|
|
295
|
-
const capacityErr = Object.assign(new Error('no capacity'), {
|
|
296
|
-
errorData: { code: 'NO_CAPACITY_MATCH' },
|
|
297
|
-
httpStatus: 503,
|
|
298
|
-
});
|
|
299
|
-
// Both tier-1 and tier-2 fail
|
|
300
|
-
api.callApi.mockRejectedValue(capacityErr);
|
|
301
|
-
|
|
302
|
-
const errLines = [];
|
|
303
|
-
console.error.mockImplementation(msg => errLines.push(msg));
|
|
304
|
-
|
|
305
|
-
await serveCommand(config, ['invalid/nonexistent-model-xyz', '--max-cost', '5'], chalk);
|
|
306
|
-
|
|
307
|
-
expect(process.exitCode).toBe(1);
|
|
308
|
-
const combined = errLines.join('\n');
|
|
309
|
-
expect(combined).toMatch(/No suitable GPU capacity|capacity/i);
|
|
310
|
-
// No receipt should reflect billing — addDeployment was never called
|
|
311
|
-
expect(store.addDeployment).not.toHaveBeenCalled();
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
it('surfaces PROVISIONING_FAILED for private HF repo without credentials', async () => {
|
|
315
|
-
const provErr = Object.assign(new Error('provisioning failed'), {
|
|
316
|
-
errorData: { code: 'PROVISIONING_FAILED', debug_error: 'HF_TOKEN missing, repo is private' },
|
|
317
|
-
httpStatus: 503,
|
|
318
|
-
});
|
|
319
|
-
// tier-1 provisioning fails; tier-2 is also tried and fails
|
|
320
|
-
api.callApi.mockRejectedValue(provErr);
|
|
321
|
-
|
|
322
|
-
const errLines = [];
|
|
323
|
-
console.error.mockImplementation(msg => errLines.push(msg));
|
|
324
|
-
|
|
325
|
-
await serveCommand(config, ['my-org/private-model', '--max-cost', '5'], chalk);
|
|
326
|
-
|
|
327
|
-
expect(process.exitCode).toBe(1);
|
|
328
|
-
// Debug info hidden by default (BADGR_DEBUG not set)
|
|
329
|
-
expect(errLines.join('\n')).not.toContain('HF_TOKEN missing');
|
|
330
|
-
// Clear failure message provided
|
|
331
|
-
expect(errLines.join('\n')).toMatch(/failed to start|try again/i);
|
|
332
|
-
expect(store.addDeployment).not.toHaveBeenCalled();
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
it('surfaces PROVISIONING_FAILED for incompatible model/template combination', async () => {
|
|
336
|
-
const provErr = Object.assign(new Error('provisioning failed'), {
|
|
337
|
-
errorData: { code: 'PROVISIONING_FAILED', debug_error: 'chat template mismatch' },
|
|
338
|
-
httpStatus: 503,
|
|
339
|
-
});
|
|
340
|
-
api.callApi.mockRejectedValue(provErr);
|
|
341
|
-
|
|
342
|
-
await serveCommand(config, ['some/model', '--task', 'unknown_task', '--max-cost', '5'], chalk);
|
|
343
|
-
|
|
344
|
-
expect(process.exitCode).toBe(1);
|
|
345
|
-
expect(store.addDeployment).not.toHaveBeenCalled();
|
|
346
|
-
});
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
350
|
-
// 6. --no-wait skips health check
|
|
351
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
352
|
-
|
|
353
|
-
describe('--no-wait flag', () => {
|
|
354
|
-
it('returns immediately without polling deployment status for readiness', async () => {
|
|
355
|
-
api.callApi.mockResolvedValueOnce(makeServeDep());
|
|
356
|
-
|
|
357
|
-
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--no-wait', '--max-cost', '5'], chalk);
|
|
358
|
-
await vi.advanceTimersByTimeAsync(100);
|
|
359
|
-
await p;
|
|
360
|
-
|
|
361
|
-
// Only the POST /serve call — no readiness polling at all.
|
|
362
|
-
expect(api.callApi).toHaveBeenCalledTimes(1);
|
|
363
|
-
// Receipt records 'starting' (not 'ready') when --no-wait is used
|
|
364
|
-
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
365
|
-
status: 'starting',
|
|
366
|
-
}));
|
|
367
|
-
expect(process.exitCode).toBeFalsy();
|
|
368
|
-
});
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
372
|
-
// 7. Custom image health check behaviour
|
|
373
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
374
|
-
|
|
375
|
-
describe('custom image health check', () => {
|
|
376
|
-
it('skips health check for non-comfyui --image (no known readiness path)', async () => {
|
|
377
|
-
api.callApi.mockResolvedValueOnce(makeServeDep({ model: null }));
|
|
378
|
-
|
|
379
|
-
const p = serveCommand(
|
|
380
|
-
config,
|
|
381
|
-
['--image', 'ghcr.io/my-org/diffusers-api:latest', '--gpu', 'L40S', '--max-cost', '5'],
|
|
382
|
-
chalk,
|
|
383
|
-
);
|
|
384
|
-
await vi.advanceTimersByTimeAsync(100);
|
|
385
|
-
await p;
|
|
386
|
-
|
|
387
|
-
expect(api.callApi).toHaveBeenCalledTimes(1);
|
|
388
|
-
expect(process.exitCode).toBeFalsy();
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
it('auto-detects comfyui image and waits on backend-reported endpoint_ready', async () => {
|
|
392
|
-
api.callApi
|
|
393
|
-
.mockResolvedValueOnce(makeServeDep({ model: null })) // POST /serve
|
|
394
|
-
.mockResolvedValueOnce({ status: 'running' }) // pre-health dep status check
|
|
395
|
-
.mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/system_stats' });
|
|
396
|
-
|
|
397
|
-
const p = serveCommand(
|
|
398
|
-
config,
|
|
399
|
-
['--image', 'ghcr.io/my-org/comfyui-custom:latest', '--gpu', 'A100', '--max-cost', '5'],
|
|
400
|
-
chalk,
|
|
401
|
-
);
|
|
402
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
403
|
-
await p;
|
|
404
|
-
|
|
405
|
-
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({ status: 'ready' }));
|
|
406
|
-
expect(process.exitCode).toBeFalsy();
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
it('sends --health-path override to the backend when explicitly provided', async () => {
|
|
410
|
-
api.callApi
|
|
411
|
-
.mockResolvedValueOnce(makeServeDep({ model: null })) // POST /serve
|
|
412
|
-
.mockResolvedValueOnce({ status: 'running' }) // pre-health dep status check
|
|
413
|
-
.mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/health' });
|
|
414
|
-
|
|
415
|
-
const p = serveCommand(
|
|
416
|
-
config,
|
|
417
|
-
['--image', 'ghcr.io/my-org/stable-diffusion:latest', '--health-path', '/health', '--max-cost', '5'],
|
|
418
|
-
chalk,
|
|
419
|
-
);
|
|
420
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
421
|
-
await p;
|
|
422
|
-
|
|
423
|
-
const body = api.callApi.mock.calls[0][1].body;
|
|
424
|
-
expect(body.health_path).toBe('/health');
|
|
425
|
-
expect(process.exitCode).toBeFalsy();
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
it('sends --idle-timeout to the backend as idle_timeout_minutes', async () => {
|
|
429
|
-
api.callApi
|
|
430
|
-
.mockResolvedValueOnce(makeServeDep())
|
|
431
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
432
|
-
.mockResolvedValueOnce(readyStatus());
|
|
433
|
-
|
|
434
|
-
const p = serveCommand(
|
|
435
|
-
config,
|
|
436
|
-
['meta-llama/Llama-3.1-8B-Instruct', '--idle-timeout', '30', '--max-cost', '5'],
|
|
437
|
-
chalk,
|
|
438
|
-
);
|
|
439
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
440
|
-
await p;
|
|
441
|
-
|
|
442
|
-
const body = api.callApi.mock.calls[0][1].body;
|
|
443
|
-
expect(body.idle_timeout_minutes).toBe(30);
|
|
444
|
-
expect(process.exitCode).toBeFalsy();
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
it('omits idle_timeout_minutes when --idle-timeout is not passed', async () => {
|
|
448
|
-
api.callApi
|
|
449
|
-
.mockResolvedValueOnce(makeServeDep())
|
|
450
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
451
|
-
.mockResolvedValueOnce(readyStatus());
|
|
452
|
-
|
|
453
|
-
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
|
|
454
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
455
|
-
await p;
|
|
456
|
-
|
|
457
|
-
const body = api.callApi.mock.calls[0][1].body;
|
|
458
|
-
expect(body.idle_timeout_minutes).toBeUndefined();
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
it('prints the per-endpoint API key exactly once when the backend returns one', async () => {
|
|
462
|
-
api.callApi
|
|
463
|
-
.mockResolvedValueOnce(makeServeDep({ endpoint_api_key: 'bge_test_key_xyz' }))
|
|
464
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
465
|
-
.mockResolvedValueOnce(readyStatus());
|
|
466
|
-
|
|
467
|
-
const lines = [];
|
|
468
|
-
console.log.mockImplementation((...args) => lines.push(args.join(' ')));
|
|
469
|
-
|
|
470
|
-
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
|
|
471
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
472
|
-
await p;
|
|
473
|
-
|
|
474
|
-
const keyLine = lines.find(l => l.includes('API key:'));
|
|
475
|
-
expect(keyLine).toContain('bge_test_key_xyz');
|
|
476
|
-
const sdkLine = lines.find(l => l.includes('api_key='));
|
|
477
|
-
expect(sdkLine).toContain('bge_test_key_xyz');
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
it('prints a heartbeat reminder when --idle-timeout is set', async () => {
|
|
481
|
-
api.callApi
|
|
482
|
-
.mockResolvedValueOnce(makeServeDep({ endpoint_api_key: 'bge_test_key_xyz' }))
|
|
483
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
484
|
-
.mockResolvedValueOnce(readyStatus());
|
|
485
|
-
|
|
486
|
-
const lines = [];
|
|
487
|
-
console.log.mockImplementation((...args) => lines.push(args.join(' ')));
|
|
488
|
-
|
|
489
|
-
const p = serveCommand(
|
|
490
|
-
config,
|
|
491
|
-
['meta-llama/Llama-3.1-8B-Instruct', '--idle-timeout', '15', '--max-cost', '5'],
|
|
492
|
-
chalk,
|
|
493
|
-
);
|
|
494
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
495
|
-
await p;
|
|
496
|
-
|
|
497
|
-
expect(lines.some(l => l.includes('badgr heartbeat dep-serve-001'))).toBe(true);
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
it('--health-path also works for model-based serve (overrides /models default)', async () => {
|
|
501
|
-
api.callApi
|
|
502
|
-
.mockResolvedValueOnce(makeServeDep()) // POST /serve
|
|
503
|
-
.mockResolvedValueOnce({ status: 'running' }) // pre-health dep status check
|
|
504
|
-
.mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/v1/health' });
|
|
505
|
-
|
|
506
|
-
const p = serveCommand(
|
|
507
|
-
config,
|
|
508
|
-
['meta-llama/Llama-3.1-8B-Instruct', '--health-path', '/v1/health', '--max-cost', '5'],
|
|
509
|
-
chalk,
|
|
510
|
-
);
|
|
511
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
512
|
-
await p;
|
|
513
|
-
|
|
514
|
-
const body = api.callApi.mock.calls[0][1].body;
|
|
515
|
-
expect(body.health_path).toBe('/v1/health');
|
|
516
|
-
expect(process.exitCode).toBeFalsy();
|
|
517
|
-
});
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
521
|
-
// 8. Routing: tier-1 unavailable → auto-expand to tier-2
|
|
522
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
523
|
-
|
|
524
|
-
describe('tier-2 fallback for serve', () => {
|
|
525
|
-
it('expands to tier-2 when tier-1 returns NO_CAPACITY_MATCH', async () => {
|
|
526
|
-
const capacityErr = Object.assign(new Error('no capacity'), {
|
|
527
|
-
errorData: { code: 'NO_CAPACITY_MATCH' },
|
|
528
|
-
httpStatus: 503,
|
|
529
|
-
});
|
|
530
|
-
api.callApi
|
|
531
|
-
.mockRejectedValueOnce(capacityErr) // tier-1
|
|
532
|
-
.mockResolvedValueOnce(makeServeDep({ provider: 'vast', tier: '2' })) // tier-2
|
|
533
|
-
.mockResolvedValueOnce({ status: 'running' }) // pre-health
|
|
534
|
-
.mockResolvedValueOnce(readyStatus());
|
|
535
|
-
|
|
536
|
-
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
|
|
537
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
538
|
-
await p;
|
|
539
|
-
|
|
540
|
-
const postCalls = api.callApi.mock.calls.filter(c => c[1]?.method === 'POST');
|
|
541
|
-
expect(postCalls.length).toBeGreaterThanOrEqual(2);
|
|
542
|
-
expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({ tier: '2' }));
|
|
543
|
-
expect(process.exitCode).toBeFalsy();
|
|
544
|
-
});
|
|
545
|
-
|
|
546
|
-
it('--no-fallback prevents tier-2 expansion for serve', async () => {
|
|
547
|
-
const capacityErr = Object.assign(new Error('no capacity'), {
|
|
548
|
-
errorData: { code: 'NO_CAPACITY_MATCH' },
|
|
549
|
-
httpStatus: 503,
|
|
550
|
-
});
|
|
551
|
-
api.callApi.mockRejectedValueOnce(capacityErr);
|
|
552
|
-
|
|
553
|
-
await serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--no-fallback', '--max-cost', '5'], chalk);
|
|
554
|
-
|
|
555
|
-
const postCalls = api.callApi.mock.calls.filter(c => c[1]?.method === 'POST');
|
|
556
|
-
expect(postCalls.length).toBe(1);
|
|
557
|
-
expect(process.exitCode).toBe(1);
|
|
558
|
-
});
|
|
559
|
-
});
|
|
560
|
-
|
|
561
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
562
|
-
// 9. Missing endpoint URL guard
|
|
563
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
564
|
-
|
|
565
|
-
describe('missing endpoint URL guard', () => {
|
|
566
|
-
it('exits with error when backend returns no endpoint_url', async () => {
|
|
567
|
-
api.callApi.mockResolvedValueOnce(makeServeDep({ endpoint_url: null, openai_base_url: null }));
|
|
568
|
-
|
|
569
|
-
await serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
|
|
570
|
-
|
|
571
|
-
expect(process.exitCode).toBe(1);
|
|
572
|
-
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
573
|
-
status: 'no_endpoint_url',
|
|
574
|
-
}));
|
|
575
|
-
});
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
579
|
-
// 10. Input validation
|
|
580
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
581
|
-
|
|
582
|
-
describe('input validation for serve', () => {
|
|
583
|
-
it('rejects missing model and --image', async () => {
|
|
584
|
-
await serveCommand(config, [], chalk);
|
|
585
|
-
expect(api.callApi).not.toHaveBeenCalled();
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
it('rejects --count 0', async () => {
|
|
589
|
-
await serveCommand(config, ['my/model', '--count', '0'], chalk);
|
|
590
|
-
expect(api.callApi).not.toHaveBeenCalled();
|
|
591
|
-
expect(process.exitCode).toBe(1);
|
|
592
|
-
});
|
|
593
|
-
|
|
594
|
-
it('rejects invalid --region', async () => {
|
|
595
|
-
await serveCommand(config, ['my/model', '--region', 'MOON'], chalk);
|
|
596
|
-
expect(api.callApi).not.toHaveBeenCalled();
|
|
597
|
-
expect(process.exitCode).toBe(1);
|
|
598
|
-
});
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
602
|
-
// --list-aliases — discoverability for blessed vLLM aliases
|
|
603
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
604
|
-
|
|
605
|
-
describe('--list-aliases', () => {
|
|
606
|
-
it('prints tested model routes without requiring a model or API call', async () => {
|
|
607
|
-
await serveCommand({ apiKey: null, baseUrl: 'https://api.test/v1' }, ['--list-aliases'], chalk);
|
|
608
|
-
expect(api.callApi).not.toHaveBeenCalled();
|
|
609
|
-
expect(process.exitCode).toBeFalsy();
|
|
610
|
-
const logged = console.log.mock.calls.flat().join('\n');
|
|
611
|
-
expect(logged).toContain('Tested model routes:');
|
|
612
|
-
expect(logged).toContain('qwen-7b');
|
|
613
|
-
expect(logged).toContain('You can also try a Hugging Face model ID:');
|
|
614
|
-
});
|
|
615
|
-
});
|
|
616
|
-
|
|
617
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
618
|
-
// Model support-level messaging — blessed route vs best-effort HF model vs
|
|
619
|
-
// custom container, plus gated-model HF_TOKEN guidance shown only on failure.
|
|
620
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
621
|
-
|
|
622
|
-
describe('model support-level messaging', () => {
|
|
623
|
-
it('labels a full Hugging Face model ID as best-effort, not tested', async () => {
|
|
624
|
-
api.callApi
|
|
625
|
-
.mockResolvedValueOnce(makeServeDep({ model: 'Qwen/Qwen2.5-7B-Instruct' }))
|
|
626
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
627
|
-
.mockResolvedValueOnce(readyStatus());
|
|
628
|
-
|
|
629
|
-
const p = serveCommand(config, ['Qwen/Qwen2.5-7B-Instruct', '--max-cost', '10'], chalk);
|
|
630
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
631
|
-
await p;
|
|
632
|
-
|
|
633
|
-
const logged = console.log.mock.calls.flat().join('\n');
|
|
634
|
-
expect(logged).toContain('Route: best-effort Hugging Face model');
|
|
635
|
-
expect(logged).not.toContain('Route: tested');
|
|
636
|
-
});
|
|
637
|
-
|
|
638
|
-
it('labels a blessed alias as a tested route without extra caveats', async () => {
|
|
639
|
-
api.callApi
|
|
640
|
-
.mockResolvedValueOnce(makeServeDep({ model: 'Qwen/Qwen2.5-7B-Instruct' }))
|
|
641
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
642
|
-
.mockResolvedValueOnce(readyStatus());
|
|
643
|
-
|
|
644
|
-
const p = serveCommand(config, ['qwen-7b', '--max-cost', '10'], chalk);
|
|
645
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
646
|
-
await p;
|
|
647
|
-
|
|
648
|
-
const logged = console.log.mock.calls.flat().join('\n');
|
|
649
|
-
expect(logged).toContain('Route: tested');
|
|
650
|
-
expect(logged).not.toContain('best-effort');
|
|
651
|
-
});
|
|
652
|
-
|
|
653
|
-
it('does not show HF_TOKEN guidance up front for a gated model that launches fine', async () => {
|
|
654
|
-
api.callApi
|
|
655
|
-
.mockResolvedValueOnce(makeServeDep({ model: 'meta-llama/Llama-3.1-8B-Instruct' }))
|
|
656
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
657
|
-
.mockResolvedValueOnce(readyStatus());
|
|
658
|
-
|
|
659
|
-
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '10'], chalk);
|
|
660
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
661
|
-
await p;
|
|
662
|
-
|
|
663
|
-
const logged = console.log.mock.calls.flat().join('\n');
|
|
664
|
-
expect(logged).not.toContain('may require Hugging Face access');
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
it('shows HF_TOKEN guidance only once a gated model actually fails to start', async () => {
|
|
668
|
-
api.callApi
|
|
669
|
-
.mockResolvedValueOnce(makeServeDep({ model: 'meta-llama/Llama-3.1-8B-Instruct' }))
|
|
670
|
-
.mockResolvedValueOnce({ status: 'failed', error: 'gated repo — 401' });
|
|
671
|
-
|
|
672
|
-
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '10'], chalk);
|
|
673
|
-
await p;
|
|
674
|
-
|
|
675
|
-
const logged = console.error.mock.calls.flat().join('\n');
|
|
676
|
-
expect(logged).toContain('may require Hugging Face access');
|
|
677
|
-
expect(logged).toContain('--env HF_TOKEN=$HF_TOKEN');
|
|
678
|
-
expect(process.exitCode).toBe(1);
|
|
679
|
-
});
|
|
680
|
-
|
|
681
|
-
it('omits HF_TOKEN guidance on failure when HF_TOKEN is already provided', async () => {
|
|
682
|
-
api.callApi
|
|
683
|
-
.mockResolvedValueOnce(makeServeDep({ model: 'meta-llama/Llama-3.1-8B-Instruct' }))
|
|
684
|
-
.mockResolvedValueOnce({ status: 'failed', error: 'crashed' });
|
|
685
|
-
|
|
686
|
-
const p = serveCommand(config, [
|
|
687
|
-
'meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '10', '--env', 'HF_TOKEN=hf_abc123',
|
|
688
|
-
], chalk);
|
|
689
|
-
await p;
|
|
690
|
-
|
|
691
|
-
const logged = console.error.mock.calls.flat().join('\n');
|
|
692
|
-
expect(logged).not.toContain('may require Hugging Face access');
|
|
693
|
-
});
|
|
694
|
-
|
|
695
|
-
it('labels a custom container as "custom server", not a Hugging Face model', async () => {
|
|
696
|
-
api.callApi.mockResolvedValueOnce(makeServeDep({ model: undefined, image: 'my/custom-server:latest' }));
|
|
697
|
-
|
|
698
|
-
const p = serveCommand(config, [
|
|
699
|
-
'--image', 'my/custom-server:latest', '--max-cost', '10', '--no-wait',
|
|
700
|
-
], chalk);
|
|
701
|
-
await p;
|
|
702
|
-
|
|
703
|
-
const logged = console.log.mock.calls.flat().join('\n');
|
|
704
|
-
expect(logged).toContain('custom server');
|
|
705
|
-
expect(logged).toContain('Your container owns the app behavior.');
|
|
706
|
-
});
|
|
707
|
-
});
|
|
708
|
-
|
|
709
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
710
|
-
// 11. llama.cpp runtime (--runtime llama.cpp --gguf)
|
|
711
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
712
|
-
|
|
713
|
-
describe('--runtime llama.cpp', () => {
|
|
714
|
-
const HF_REPO = 'HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive';
|
|
715
|
-
const HF_FILE = 'Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf';
|
|
716
|
-
|
|
717
|
-
it('errors when --hf-repo or --hf-file are missing', async () => {
|
|
718
|
-
await serveCommand(config, ['--runtime', 'llama.cpp', '--max-cost', '10'], chalk);
|
|
719
|
-
expect(process.exitCode).toBe(1);
|
|
720
|
-
expect(api.callApi).not.toHaveBeenCalled();
|
|
721
|
-
});
|
|
722
|
-
|
|
723
|
-
it('errors when only --hf-repo is given (missing --hf-file)', async () => {
|
|
724
|
-
await serveCommand(config, ['--runtime', 'llama.cpp', '--hf-repo', HF_REPO, '--max-cost', '10'], chalk);
|
|
725
|
-
expect(process.exitCode).toBe(1);
|
|
726
|
-
expect(api.callApi).not.toHaveBeenCalled();
|
|
727
|
-
});
|
|
728
|
-
|
|
729
|
-
it('uses llama.cpp image with LLAMA_ARG_HF_REPO and LLAMA_ARG_HF_FILE env vars', async () => {
|
|
730
|
-
// --no-wait: only POST /serve called (1 mock needed)
|
|
731
|
-
api.callApi.mockResolvedValueOnce(makeServeDep());
|
|
732
|
-
|
|
733
|
-
const p = serveCommand(
|
|
734
|
-
config,
|
|
735
|
-
['--runtime', 'llama.cpp', '--hf-repo', HF_REPO, '--hf-file', HF_FILE, '--max-cost', '10', '--no-wait'],
|
|
736
|
-
chalk,
|
|
737
|
-
);
|
|
738
|
-
await vi.advanceTimersByTimeAsync(100);
|
|
739
|
-
await p;
|
|
740
|
-
|
|
741
|
-
const body = api.callApi.mock.calls[0][1].body;
|
|
742
|
-
expect(body.image).toBe('michaelmanleyx/llama-cpp:server-cuda');
|
|
743
|
-
expect(body.env.LLAMA_ARG_HF_REPO).toBe(HF_REPO);
|
|
744
|
-
expect(body.env.LLAMA_ARG_HF_FILE).toBe(HF_FILE);
|
|
745
|
-
expect(body.model).toBeUndefined();
|
|
746
|
-
expect(process.exitCode).toBeFalsy();
|
|
747
|
-
});
|
|
748
|
-
|
|
749
|
-
it('records deployment and receipt as ready on success', async () => {
|
|
750
|
-
api.callApi
|
|
751
|
-
.mockResolvedValueOnce(makeServeDep())
|
|
752
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
753
|
-
.mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/health' });
|
|
754
|
-
|
|
755
|
-
const p = serveCommand(
|
|
756
|
-
config,
|
|
757
|
-
['--runtime', 'llama.cpp', '--hf-repo', HF_REPO, '--hf-file', HF_FILE, '--max-cost', '10'],
|
|
758
|
-
chalk,
|
|
759
|
-
);
|
|
760
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
761
|
-
await p;
|
|
762
|
-
|
|
763
|
-
expect(store.addDeployment).toHaveBeenCalledWith(expect.objectContaining({
|
|
764
|
-
id: 'dep-serve-001',
|
|
765
|
-
}));
|
|
766
|
-
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
767
|
-
status: 'ready',
|
|
768
|
-
}));
|
|
769
|
-
expect(process.exitCode).toBeFalsy();
|
|
770
|
-
});
|
|
771
|
-
|
|
772
|
-
it('merges user --env with HF env vars', async () => {
|
|
773
|
-
// --no-wait: only POST /serve called (1 mock needed)
|
|
774
|
-
api.callApi.mockResolvedValueOnce(makeServeDep());
|
|
775
|
-
|
|
776
|
-
const p = serveCommand(
|
|
777
|
-
config,
|
|
778
|
-
['--runtime', 'llama.cpp', '--hf-repo', HF_REPO, '--hf-file', HF_FILE,
|
|
779
|
-
'--env', 'CTX_SIZE=8192', '--max-cost', '10', '--no-wait'],
|
|
780
|
-
chalk,
|
|
781
|
-
);
|
|
782
|
-
await vi.advanceTimersByTimeAsync(100);
|
|
783
|
-
await p;
|
|
784
|
-
|
|
785
|
-
const body = api.callApi.mock.calls[0][1].body;
|
|
786
|
-
expect(body.env.LLAMA_ARG_HF_REPO).toBe(HF_REPO);
|
|
787
|
-
expect(body.env.LLAMA_ARG_HF_FILE).toBe(HF_FILE);
|
|
788
|
-
expect(body.env.CTX_SIZE).toBe('8192');
|
|
789
|
-
});
|
|
790
|
-
});
|
|
791
|
-
|
|
792
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
793
|
-
// 12. E2E smoke test — tiny HF GGUF (ggml-org/tiny-llamas / stories260K.gguf)
|
|
794
|
-
//
|
|
795
|
-
// Uses a real, publicly-available tiny GGUF (~1 MB) to verify the full
|
|
796
|
-
// request lifecycle: arg parsing → correct serve body → readiness polling via
|
|
797
|
-
// Badgr's own deployment status → receipt recorded as ready → OpenAI-compatible
|
|
798
|
-
// base URL returned.
|
|
799
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
800
|
-
|
|
801
|
-
describe('llama.cpp E2E smoke — tiny GGUF (ggml-org/tiny-llamas)', () => {
|
|
802
|
-
const TINY_REPO = 'ggml-org/tiny-llamas';
|
|
803
|
-
const TINY_FILE = 'stories260K.gguf';
|
|
804
|
-
|
|
805
|
-
it('full lifecycle: arg parse → serve body → readiness poll → ready receipt', async () => {
|
|
806
|
-
api.callApi
|
|
807
|
-
.mockResolvedValueOnce(makeServeDep({ model: undefined })) // POST /serve
|
|
808
|
-
.mockResolvedValueOnce({ status: 'running' }) // pre-health dep check
|
|
809
|
-
.mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/health' });
|
|
810
|
-
|
|
811
|
-
const p = serveCommand(
|
|
812
|
-
config,
|
|
813
|
-
['--runtime', 'llama.cpp',
|
|
814
|
-
'--hf-repo', TINY_REPO,
|
|
815
|
-
'--hf-file', TINY_FILE,
|
|
816
|
-
'--max-cost', '1'],
|
|
817
|
-
chalk,
|
|
818
|
-
);
|
|
819
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
820
|
-
await p;
|
|
821
|
-
|
|
822
|
-
// Correct image and env vars in the serve request
|
|
823
|
-
const body = api.callApi.mock.calls[0][1].body;
|
|
824
|
-
expect(body.image).toBe('michaelmanleyx/llama-cpp:server-cuda');
|
|
825
|
-
expect(body.env.LLAMA_ARG_HF_REPO).toBe(TINY_REPO);
|
|
826
|
-
expect(body.env.LLAMA_ARG_HF_FILE).toBe(TINY_FILE);
|
|
827
|
-
expect(body.model).toBeUndefined();
|
|
828
|
-
|
|
829
|
-
// Receipt finalised as ready with endpoint URL
|
|
830
|
-
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
831
|
-
status: 'ready',
|
|
832
|
-
endpointUrl: ENDPOINT_URL,
|
|
833
|
-
}));
|
|
834
|
-
|
|
835
|
-
expect(process.exitCode).toBeFalsy();
|
|
836
|
-
});
|
|
837
|
-
});
|
|
838
|
-
|
|
839
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
840
|
-
// 13. --task routing: transcribe and image use /health; embed uses /models
|
|
841
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
842
|
-
|
|
843
|
-
describe('--task routing for managed runtimes', () => {
|
|
844
|
-
it('--task transcribe becomes ready via backend readiness (health_path=/health)', async () => {
|
|
845
|
-
api.callApi
|
|
846
|
-
.mockResolvedValueOnce(makeServeDep({ model: 'large-v3' })) // POST /serve
|
|
847
|
-
.mockResolvedValueOnce({ status: 'running' }) // pre-health dep check
|
|
848
|
-
.mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/health' });
|
|
849
|
-
|
|
850
|
-
const p = serveCommand(
|
|
851
|
-
config,
|
|
852
|
-
['large-v3', '--task', 'transcribe', '--max-cost', '5'],
|
|
853
|
-
chalk,
|
|
854
|
-
);
|
|
855
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
856
|
-
await p;
|
|
857
|
-
|
|
858
|
-
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({ status: 'ready' }));
|
|
859
|
-
expect(process.exitCode).toBeFalsy();
|
|
860
|
-
});
|
|
861
|
-
|
|
862
|
-
it('--task transcribe sends task field to backend', async () => {
|
|
863
|
-
api.callApi.mockResolvedValueOnce(makeServeDep({ model: 'large-v3' }));
|
|
864
|
-
|
|
865
|
-
const p = serveCommand(
|
|
866
|
-
config,
|
|
867
|
-
['large-v3', '--task', 'transcribe', '--max-cost', '5', '--no-wait'],
|
|
868
|
-
chalk,
|
|
869
|
-
);
|
|
870
|
-
await vi.advanceTimersByTimeAsync(100);
|
|
871
|
-
await p;
|
|
872
|
-
|
|
873
|
-
const body = api.callApi.mock.calls[0][1].body;
|
|
874
|
-
expect(body.task).toBe('transcribe');
|
|
875
|
-
expect(body.model).toBe('large-v3');
|
|
876
|
-
expect(process.exitCode).toBeFalsy();
|
|
877
|
-
});
|
|
878
|
-
|
|
879
|
-
it('--task image becomes ready via backend readiness (health_path=/health)', async () => {
|
|
880
|
-
api.callApi
|
|
881
|
-
.mockResolvedValueOnce(makeServeDep({ model: 'black-forest-labs/FLUX.1-schnell' }))
|
|
882
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
883
|
-
.mockResolvedValueOnce({ status: 'running', endpoint_ready: true, health_path: '/health' });
|
|
884
|
-
|
|
885
|
-
const p = serveCommand(
|
|
886
|
-
config,
|
|
887
|
-
['black-forest-labs/FLUX.1-schnell', '--task', 'image', '--max-cost', '10'],
|
|
888
|
-
chalk,
|
|
889
|
-
);
|
|
890
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
891
|
-
await p;
|
|
892
|
-
|
|
893
|
-
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({ status: 'ready' }));
|
|
894
|
-
expect(process.exitCode).toBeFalsy();
|
|
895
|
-
});
|
|
896
|
-
|
|
897
|
-
it('--task image sends task field to backend', async () => {
|
|
898
|
-
api.callApi.mockResolvedValueOnce(makeServeDep({ model: 'black-forest-labs/FLUX.1-schnell' }));
|
|
899
|
-
|
|
900
|
-
const p = serveCommand(
|
|
901
|
-
config,
|
|
902
|
-
['black-forest-labs/FLUX.1-schnell', '--task', 'image', '--max-cost', '10', '--no-wait'],
|
|
903
|
-
chalk,
|
|
904
|
-
);
|
|
905
|
-
await vi.advanceTimersByTimeAsync(100);
|
|
906
|
-
await p;
|
|
907
|
-
|
|
908
|
-
const body = api.callApi.mock.calls[0][1].body;
|
|
909
|
-
expect(body.task).toBe('image');
|
|
910
|
-
expect(body.model).toBe('black-forest-labs/FLUX.1-schnell');
|
|
911
|
-
expect(process.exitCode).toBeFalsy();
|
|
912
|
-
});
|
|
913
|
-
|
|
914
|
-
it('--task embed becomes ready via backend readiness (health_path=/v1/models)', async () => {
|
|
915
|
-
api.callApi
|
|
916
|
-
.mockResolvedValueOnce(makeServeDep({ model: 'BAAI/bge-large-en-v1.5' }))
|
|
917
|
-
.mockResolvedValueOnce({ status: 'running' })
|
|
918
|
-
.mockResolvedValueOnce(readyStatus());
|
|
919
|
-
|
|
920
|
-
const p = serveCommand(
|
|
921
|
-
config,
|
|
922
|
-
['BAAI/bge-large-en-v1.5', '--task', 'embed', '--max-cost', '5'],
|
|
923
|
-
chalk,
|
|
924
|
-
);
|
|
925
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
926
|
-
await p;
|
|
927
|
-
|
|
928
|
-
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({ status: 'ready' }));
|
|
929
|
-
expect(process.exitCode).toBeFalsy();
|
|
930
|
-
});
|
|
931
|
-
});
|