badgr-cli 1.0.44 → 1.0.45
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/README.md +178 -240
- package/package.json +1 -1
- package/src/badgr.js +12 -0
- package/src/catalog.js +31 -0
- package/src/commands/comfyui.js +70 -56
- package/src/commands/detect.js +58 -0
- package/src/commands/receipts.js +39 -2
- package/src/commands/run.js +78 -3
- package/src/commands/serve.js +116 -5
- package/src/commands/train.js +22 -27
- package/src/detect.js +362 -0
- package/src/progress.js +160 -0
- package/src/store.js +11 -0
- package/tests/detect.test.js +191 -0
- package/tests/job-progress-poll.test.js +136 -0
- package/tests/productized-runners.test.js +7 -0
- package/tests/run-lifecycle.test.js +111 -1
- package/tests/serve-apps.test.js +189 -0
- package/tests/serve-lifecycle.test.js +21 -0
- package/tests/store.test.js +22 -1
- package/tests/template.test.js +4 -4
- package/tests/workload-templates.test.js +22 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `badgr serve openwebui` — a chat UI that connects to a model endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Reuses a running vLLM endpoint for the requested model if one exists, or
|
|
5
|
+
* launches one first, then wires OPENAI_API_BASE_URL/OPENAI_API_KEY to it —
|
|
6
|
+
* via the same serveCommand/template machinery `badgr serve template <name>`
|
|
7
|
+
* already uses. No new provisioning path.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
10
|
+
import { serveCommand } from '../src/commands/serve.js';
|
|
11
|
+
|
|
12
|
+
vi.mock('../src/api.js', () => ({
|
|
13
|
+
callApi: vi.fn(),
|
|
14
|
+
terminateDeployment: vi.fn().mockResolvedValue({}),
|
|
15
|
+
listDeployments: vi.fn().mockResolvedValue({ deployments: [], count: 0 }),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock('../src/store.js', () => ({
|
|
19
|
+
addDeployment: vi.fn(),
|
|
20
|
+
addReceipt: vi.fn(),
|
|
21
|
+
updateReceipt: vi.fn(),
|
|
22
|
+
generateReceiptId: vi.fn(() => 'rcpt-app-001'),
|
|
23
|
+
generateDeploymentId: vi.fn(() => 'dep-app-001'),
|
|
24
|
+
listDeployments: vi.fn(() => []),
|
|
25
|
+
listReceipts: vi.fn(() => []),
|
|
26
|
+
findDeployment: vi.fn(() => null),
|
|
27
|
+
updateDeployment: vi.fn(),
|
|
28
|
+
removeDeployment: vi.fn(),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
import * as api from '../src/api.js';
|
|
32
|
+
import * as store from '../src/store.js';
|
|
33
|
+
|
|
34
|
+
const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
|
|
35
|
+
const chalk = {
|
|
36
|
+
bold: s => s, dim: s => s, red: s => s, yellow: s => s, green: s => s, cyan: s => s,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function makeServeDep(overrides = {}) {
|
|
40
|
+
return {
|
|
41
|
+
deployment_id: 'dep-app-001',
|
|
42
|
+
status: 'running',
|
|
43
|
+
gpu_type: 'RTX_4090',
|
|
44
|
+
gpu_count: 1,
|
|
45
|
+
cost_per_hour: 0.50,
|
|
46
|
+
provider: 'runpod',
|
|
47
|
+
receipt_id: 'rcpt-app-001',
|
|
48
|
+
tier: '1',
|
|
49
|
+
endpoint_url: 'https://dep-app-001.aibadgr.com/v1',
|
|
50
|
+
...overrides,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Queues one POST /serve → status → ready cycle onto api.callApi.
|
|
55
|
+
function queueServeCycle(depOverrides = {}) {
|
|
56
|
+
api.callApi
|
|
57
|
+
.mockResolvedValueOnce(makeServeDep(depOverrides))
|
|
58
|
+
.mockResolvedValueOnce({ status: 'running' })
|
|
59
|
+
.mockResolvedValueOnce({ status: 'running', endpoint_ready: true });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
vi.useFakeTimers();
|
|
64
|
+
process.exitCode = undefined;
|
|
65
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
66
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
67
|
+
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
68
|
+
vi.clearAllMocks();
|
|
69
|
+
store.generateReceiptId.mockReturnValue('rcpt-app-001');
|
|
70
|
+
api.terminateDeployment.mockResolvedValue({});
|
|
71
|
+
api.listDeployments.mockResolvedValue({ deployments: [], count: 0 });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('badgr serve openwebui — reuses a running vLLM endpoint', () => {
|
|
75
|
+
it('connects to an existing endpoint instead of launching a second one', async () => {
|
|
76
|
+
api.listDeployments.mockResolvedValueOnce({
|
|
77
|
+
deployments: [{
|
|
78
|
+
status: 'running',
|
|
79
|
+
workload_type: 'endpoint',
|
|
80
|
+
model: 'Qwen/Qwen2.5-7B-Instruct',
|
|
81
|
+
endpoint_url: 'https://dep-existing.aibadgr.com/v1',
|
|
82
|
+
}],
|
|
83
|
+
count: 1,
|
|
84
|
+
});
|
|
85
|
+
queueServeCycle(); // only one cycle — openwebui itself
|
|
86
|
+
|
|
87
|
+
const p = serveCommand(config, ['openwebui', '--model', 'qwen-7b', '--max-cost', '5'], chalk);
|
|
88
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
89
|
+
await p;
|
|
90
|
+
|
|
91
|
+
expect(api.callApi.mock.calls.length).toBe(3); // no second /serve for vLLM
|
|
92
|
+
const [, opts] = api.callApi.mock.calls[0];
|
|
93
|
+
expect(opts.body.image).toBe('ghcr.io/open-webui/open-webui:main');
|
|
94
|
+
expect(opts.body.env.OPENAI_API_BASE_URL).toBe('https://dep-existing.aibadgr.com/v1');
|
|
95
|
+
expect(process.exitCode).toBeFalsy();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('badgr serve openwebui — launches vLLM first when none is running', () => {
|
|
100
|
+
it('serves vLLM (warning about the separate budget), then Open WebUI wired to the new endpoint', async () => {
|
|
101
|
+
// listDeployments is called 4 times: pre-launch discovery (none) →
|
|
102
|
+
// vLLM's own duplicate check (none) → post-launch discovery (found) →
|
|
103
|
+
// Open WebUI's own duplicate check (none — no image field to match).
|
|
104
|
+
api.listDeployments
|
|
105
|
+
.mockResolvedValueOnce({ deployments: [], count: 0 })
|
|
106
|
+
.mockResolvedValueOnce({ deployments: [], count: 0 })
|
|
107
|
+
.mockResolvedValueOnce({
|
|
108
|
+
deployments: [{
|
|
109
|
+
deployment_id: 'dep-vllm-new',
|
|
110
|
+
status: 'running',
|
|
111
|
+
workload_type: 'endpoint',
|
|
112
|
+
model: 'Qwen/Qwen2.5-7B-Instruct',
|
|
113
|
+
endpoint_url: 'https://dep-new.aibadgr.com/v1',
|
|
114
|
+
}],
|
|
115
|
+
count: 1,
|
|
116
|
+
});
|
|
117
|
+
queueServeCycle({ model: 'Qwen/Qwen2.5-7B-Instruct' }); // vLLM launch
|
|
118
|
+
queueServeCycle(); // Open WebUI launch
|
|
119
|
+
|
|
120
|
+
const p = serveCommand(config, ['openwebui', '--model', 'qwen-7b', '--max-cost', '5'], chalk);
|
|
121
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
122
|
+
await p;
|
|
123
|
+
|
|
124
|
+
expect(api.callApi.mock.calls.length).toBe(6);
|
|
125
|
+
const [, vllmOpts] = api.callApi.mock.calls[0];
|
|
126
|
+
expect(vllmOpts.body.model).toBe('Qwen/Qwen2.5-7B-Instruct');
|
|
127
|
+
const [, webuiOpts] = api.callApi.mock.calls[3];
|
|
128
|
+
expect(webuiOpts.body.image).toBe('ghcr.io/open-webui/open-webui:main');
|
|
129
|
+
expect(webuiOpts.body.env.OPENAI_API_BASE_URL).toBe('https://dep-new.aibadgr.com/v1');
|
|
130
|
+
expect(process.exitCode).toBeFalsy();
|
|
131
|
+
|
|
132
|
+
// --max-cost applies to both the auto-launched vLLM and Open WebUI
|
|
133
|
+
// separately — the CLI must say so rather than double-spend silently.
|
|
134
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
135
|
+
expect(logged).toContain('own --max-cost $5 cap');
|
|
136
|
+
expect(logged).toContain('Total possible spend across both deployments: ~$10.00');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('prints a badgr down hint for the auto-launched vLLM deployment if Open WebUI then fails', async () => {
|
|
140
|
+
api.listDeployments
|
|
141
|
+
.mockResolvedValueOnce({ deployments: [], count: 0 }) // pre-launch discovery
|
|
142
|
+
.mockResolvedValueOnce({ deployments: [], count: 0 }) // vLLM's own dup-check
|
|
143
|
+
.mockResolvedValueOnce({ // post-launch discovery — vLLM is up
|
|
144
|
+
deployments: [{
|
|
145
|
+
deployment_id: 'dep-vllm-999',
|
|
146
|
+
status: 'running',
|
|
147
|
+
workload_type: 'endpoint',
|
|
148
|
+
model: 'Qwen/Qwen2.5-7B-Instruct',
|
|
149
|
+
endpoint_url: 'https://dep-vllm-999.aibadgr.com/v1',
|
|
150
|
+
}],
|
|
151
|
+
count: 1,
|
|
152
|
+
})
|
|
153
|
+
.mockResolvedValueOnce({ deployments: [], count: 0 }); // Open WebUI's own dup-check
|
|
154
|
+
|
|
155
|
+
queueServeCycle({ model: 'Qwen/Qwen2.5-7B-Instruct' }); // vLLM launch succeeds
|
|
156
|
+
// Open WebUI's own POST /serve "succeeds" but hands back no endpoint URL —
|
|
157
|
+
// serveCommand's own no-endpoint-url guard fires and sets exitCode = 1.
|
|
158
|
+
api.callApi.mockResolvedValueOnce(makeServeDep({ endpoint_url: undefined }));
|
|
159
|
+
|
|
160
|
+
const p = serveCommand(config, ['openwebui', '--model', 'qwen-7b', '--max-cost', '5'], chalk);
|
|
161
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
162
|
+
await p;
|
|
163
|
+
|
|
164
|
+
expect(process.exitCode).toBe(1);
|
|
165
|
+
const logged = console.error.mock.calls.flat().join('\n');
|
|
166
|
+
expect(logged).toContain('badgr down dep-vllm-999');
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('badgr serve openwebui --connect', () => {
|
|
171
|
+
it('skips vLLM discovery/launch entirely and wires the given URL', async () => {
|
|
172
|
+
queueServeCycle();
|
|
173
|
+
const p = serveCommand(
|
|
174
|
+
config,
|
|
175
|
+
['openwebui', '--connect', 'https://my-server.example.com/v1', '--max-cost', '5'],
|
|
176
|
+
chalk,
|
|
177
|
+
);
|
|
178
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
179
|
+
await p;
|
|
180
|
+
|
|
181
|
+
// Only the one serve cycle ran (Open WebUI itself) — no vLLM discovery/launch.
|
|
182
|
+
expect(api.callApi.mock.calls.length).toBe(3);
|
|
183
|
+
const [, opts] = api.callApi.mock.calls[0];
|
|
184
|
+
expect(opts.body.env.OPENAI_API_BASE_URL).toBe('https://my-server.example.com/v1');
|
|
185
|
+
|
|
186
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
187
|
+
expect(logged).not.toContain('needs a model endpoint behind it');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -239,6 +239,27 @@ describe('deployment fails during startup', () => {
|
|
|
239
239
|
const errLines = console.error.mock.calls.flat().join('\n');
|
|
240
240
|
expect(errLines).toContain('likely an OOM');
|
|
241
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
|
+
});
|
|
242
263
|
});
|
|
243
264
|
|
|
244
265
|
// ─────────────────────────────────────────────────────────────────────────────
|
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, updateReceipt, listReceipts,
|
|
8
|
+
addReceipt, updateReceipt, listReceipts, findReceipt,
|
|
9
9
|
generateDeploymentId, generateReceiptId,
|
|
10
10
|
} from '../src/store.js';
|
|
11
11
|
|
|
@@ -125,6 +125,27 @@ describe('addReceipt / listReceipts', () => {
|
|
|
125
125
|
});
|
|
126
126
|
});
|
|
127
127
|
|
|
128
|
+
describe('findReceipt', () => {
|
|
129
|
+
// comfy.batch / train.lora mint a local rcpt- id that points at a job_id —
|
|
130
|
+
// `badgr receipts <id>` needs to resolve either the receipt id itself or
|
|
131
|
+
// the job_id back to the same local record (see receipts.js).
|
|
132
|
+
it('finds a receipt by its own receiptId', () => {
|
|
133
|
+
addReceipt({ receiptId: 'rcpt-1', type: 'comfy.batch', job_id: 'job_abc' }, file);
|
|
134
|
+
const found = findReceipt('rcpt-1', file);
|
|
135
|
+
expect(found?.job_id).toBe('job_abc');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('finds a receipt by job_id', () => {
|
|
139
|
+
addReceipt({ receiptId: 'rcpt-2', type: 'train.lora', job_id: 'job_def' }, file);
|
|
140
|
+
const found = findReceipt('job_def', file);
|
|
141
|
+
expect(found?.receiptId).toBe('rcpt-2');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('returns null when nothing matches', () => {
|
|
145
|
+
expect(findReceipt('nope', file)).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
128
149
|
describe('updateReceipt', () => {
|
|
129
150
|
it('merges updates into an existing receipt', () => {
|
|
130
151
|
addReceipt({ receiptId: 'r-upd', action: 'badgr run', status: 'running' }, file);
|
package/tests/template.test.js
CHANGED
|
@@ -105,14 +105,14 @@ afterEach(() => {
|
|
|
105
105
|
|
|
106
106
|
describe('TEMPLATES catalog', () => {
|
|
107
107
|
const EXPECTED_NAMES = [
|
|
108
|
-
'comfyui', 'axolotl', 'batch-inference', 'unsloth', 'vllm', 'llama-cpp',
|
|
108
|
+
'comfyui', 'axolotl', 'batch-inference', 'unsloth', 'vllm', 'openwebui', 'llama-cpp',
|
|
109
109
|
'invokeai', 'kohya-ss', 'text-gen-webui', 'sglang', 'tgi',
|
|
110
110
|
'auto1111', 'forge', 'nerfstudio', 'openfold', 'blender-render',
|
|
111
111
|
'openmm', 'gromacs', 'lammps', 'diffusers', 'torchtune',
|
|
112
112
|
];
|
|
113
113
|
|
|
114
|
-
it('contains exactly
|
|
115
|
-
expect(TEMPLATES).toHaveLength(
|
|
114
|
+
it('contains exactly 22 templates', () => {
|
|
115
|
+
expect(TEMPLATES).toHaveLength(22);
|
|
116
116
|
});
|
|
117
117
|
|
|
118
118
|
it('contains all expected template names', () => {
|
|
@@ -180,7 +180,7 @@ describe('TEMPLATES catalog', () => {
|
|
|
180
180
|
for (const [k, t] of Object.entries(TEMPLATE_MAP)) {
|
|
181
181
|
expect(k).toBe(t.name);
|
|
182
182
|
}
|
|
183
|
-
expect(Object.keys(TEMPLATE_MAP)).toHaveLength(
|
|
183
|
+
expect(Object.keys(TEMPLATE_MAP)).toHaveLength(22);
|
|
184
184
|
});
|
|
185
185
|
});
|
|
186
186
|
|
|
@@ -300,6 +300,28 @@ describe('comfyuiCommand', () => {
|
|
|
300
300
|
}));
|
|
301
301
|
expect(process.exitCode).toBe(1);
|
|
302
302
|
});
|
|
303
|
+
|
|
304
|
+
it('prints failure_class/next_action from the backend using the shared vocabulary', async () => {
|
|
305
|
+
fs.existsSync.mockReturnValue(true);
|
|
306
|
+
fs.readFileSync.mockReturnValue(JSON.stringify({ '1': {} }));
|
|
307
|
+
fallback.callWithFallback.mockResolvedValue(makeServeDep());
|
|
308
|
+
|
|
309
|
+
api.callApi.mockResolvedValue({
|
|
310
|
+
status: 'failed',
|
|
311
|
+
error: 'OOM',
|
|
312
|
+
failure_class: 'container_start_failed',
|
|
313
|
+
next_action: 'Check the image and command, then retry.',
|
|
314
|
+
});
|
|
315
|
+
global.fetch = vi.fn().mockRejectedValue(new Error('Connection refused'));
|
|
316
|
+
|
|
317
|
+
const p = comfyuiCommand(config, ['wf.json', '--max-cost', '5'], chalk);
|
|
318
|
+
await vi.advanceTimersByTimeAsync(10000);
|
|
319
|
+
await p;
|
|
320
|
+
|
|
321
|
+
const errLines = console.error.mock.calls.flat().join('\n');
|
|
322
|
+
expect(errLines).toContain('Class: container_start_failed');
|
|
323
|
+
expect(errLines).toContain('Next: Check the image and command, then retry.');
|
|
324
|
+
});
|
|
303
325
|
});
|
|
304
326
|
|
|
305
327
|
// ─────────────────────────────────────────────────────────────────────────────
|