badgr-cli 1.0.41 → 1.0.43
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 +69 -2
- package/package.json +2 -1
- package/src/badgr.js +3 -0
- package/src/catalog.js +38 -0
- package/src/commands/comfyui.js +159 -0
- package/src/commands/run.js +14 -33
- package/src/commands/serve.js +62 -41
- package/src/commands/train.js +204 -1
- package/tests/productized-dry-run.test.js +141 -0
- package/tests/productized-runners.test.js +230 -0
- package/tests/serve-lifecycle.test.js +103 -137
- package/tests/template.test.js +11 -13
- package/tests/workload-templates.test.js +37 -2
package/src/commands/train.js
CHANGED
|
@@ -21,6 +21,17 @@ const TRAINING_IMAGES = {
|
|
|
21
21
|
generic: 'nvidia/cuda:12.1.0-cudnn8-devel-ubuntu22.04',
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
// Container command per framework — must match what's actually installed in
|
|
25
|
+
// TRAINING_IMAGES[framework]. A framework with no entry here has no known
|
|
26
|
+
// working command and is blocked in trainCommand() rather than guessed at,
|
|
27
|
+
// since a wrong guess still provisions (and bills) the GPU before failing.
|
|
28
|
+
const TRAINING_COMMANDS = {
|
|
29
|
+
axolotl: 'echo "$TRAIN_CONFIG_B64" | base64 -d > /tmp/config.yaml && axolotl train /tmp/config.yaml',
|
|
30
|
+
// huggingface/trl-source ships the `trl` CLI (trl sft|dpo|kto --config <yaml>).
|
|
31
|
+
// We default to `sft` — the common case for a plain base_model+dataset config.
|
|
32
|
+
trl: 'echo "$TRAIN_CONFIG_B64" | base64 -d > /tmp/config.yaml && trl sft --config /tmp/config.yaml',
|
|
33
|
+
};
|
|
34
|
+
|
|
24
35
|
// Preferred GPUs for training: VRAM-heavy workloads.
|
|
25
36
|
const TRAINING_GPUS = ['A100', 'H100', 'L40S', 'A6000'];
|
|
26
37
|
|
|
@@ -87,11 +98,188 @@ export function findLocalDatasetPaths(configContent) {
|
|
|
87
98
|
return paths;
|
|
88
99
|
}
|
|
89
100
|
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// badgr train lora — productized LoRA training via POST /v1/jobs
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
export function parseTrainLoraArgs(args) {
|
|
106
|
+
const flags = {};
|
|
107
|
+
let i = 0;
|
|
108
|
+
while (i < args.length) {
|
|
109
|
+
const a = args[i];
|
|
110
|
+
if (a === '--base-model') { flags.baseModel = args[++i]; i++; continue; }
|
|
111
|
+
if (a === '--dataset') { flags.dataset = args[++i]; i++; continue; }
|
|
112
|
+
if (a === '--file-id') { flags.fileId = args[++i]; i++; continue; }
|
|
113
|
+
if (a === '--preset') { flags.preset = args[++i]; i++; continue; }
|
|
114
|
+
if (a === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
|
|
115
|
+
if (a === '--max-runtime') { flags.maxRuntime = parseFloat(args[++i]); i++; continue; }
|
|
116
|
+
if (a === '--tier') { flags.tier = args[++i]; i++; continue; }
|
|
117
|
+
if (a === '--gpu-type') { flags.gpuType = args[++i]; i++; continue; }
|
|
118
|
+
if (a === '--dry-run') { flags.dryRun = true; i++; continue; }
|
|
119
|
+
i++;
|
|
120
|
+
}
|
|
121
|
+
return flags;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Mirror of backend LORA_PRESETS (jobs_routes.py) — display only, server is authoritative.
|
|
125
|
+
export const LORA_PRESET_INFO = {
|
|
126
|
+
small: { gpu_type: 'RTX_4090', rank: 16, epochs: 3, description: 'Fast, low-cost — good default for most datasets' },
|
|
127
|
+
medium: { gpu_type: 'A100', rank: 32, epochs: 5, description: 'Larger rank/more epochs — bigger datasets or higher quality' },
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export async function trainLoraCommand(config, args, chalk) {
|
|
131
|
+
const { callApi } = await import('../api.js');
|
|
132
|
+
const { addReceipt, generateReceiptId } = await import('../store.js');
|
|
133
|
+
const flags = parseTrainLoraArgs(args);
|
|
134
|
+
|
|
135
|
+
if (!flags.baseModel) {
|
|
136
|
+
console.error(chalk.red('\n Usage: badgr train lora --base-model <model> --dataset <file-or-url> --preset small --max-cost 20\n'));
|
|
137
|
+
console.error(chalk.dim(' Dataset sources:'));
|
|
138
|
+
console.error(chalk.dim(' --dataset ./train.jsonl local file (uploaded first)'));
|
|
139
|
+
console.error(chalk.dim(' --dataset https://... direct URL'));
|
|
140
|
+
console.error(chalk.dim(' --file-id up_abc123 Badgr upload ID\n'));
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!flags.maxCost && !flags.dryRun) {
|
|
146
|
+
console.error(chalk.red('\n ✗ --max-cost is required to cap GPU spend.\n'));
|
|
147
|
+
process.exitCode = 1;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (flags.dryRun) {
|
|
152
|
+
const preset = flags.preset || 'small';
|
|
153
|
+
const presetInfo = LORA_PRESET_INFO[preset];
|
|
154
|
+
console.log(chalk.bold('\n⚡ Dry run — no GPU will be provisioned\n'));
|
|
155
|
+
console.log(` ${chalk.bold('Base model:')} ${flags.baseModel}`);
|
|
156
|
+
console.log(` ${chalk.bold('Dataset:')} ${flags.fileId || flags.dataset || chalk.dim('(none given)')}`);
|
|
157
|
+
console.log(` ${chalk.bold('Preset:')} ${preset}${presetInfo ? '' : chalk.yellow(' (unknown — server will reject this)')}`);
|
|
158
|
+
if (presetInfo) {
|
|
159
|
+
console.log(` ${chalk.bold('GPU:')} ${flags.gpuType || presetInfo.gpu_type}`);
|
|
160
|
+
console.log(` ${chalk.bold('LoRA rank:')} ${presetInfo.rank}`);
|
|
161
|
+
console.log(` ${chalk.bold('Epochs:')} ${presetInfo.epochs}`);
|
|
162
|
+
console.log(` ${chalk.dim(presetInfo.description)}`);
|
|
163
|
+
}
|
|
164
|
+
if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost}`);
|
|
165
|
+
console.log(` ${chalk.bold('Max runtime:')} ${flags.maxRuntime ?? 240}min`);
|
|
166
|
+
console.log(chalk.dim('\n Remove --dry-run to submit (local file datasets are uploaded first).\n'));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
requireApiKey(config);
|
|
171
|
+
|
|
172
|
+
// Build dataset input field
|
|
173
|
+
const input = { base_model: flags.baseModel, config_preset: flags.preset || 'small' };
|
|
174
|
+
if (flags.fileId) {
|
|
175
|
+
input.dataset_file_id = flags.fileId;
|
|
176
|
+
} else if (flags.dataset && (flags.dataset.startsWith('http://') || flags.dataset.startsWith('https://') || flags.dataset.startsWith('s3://'))) {
|
|
177
|
+
input.dataset_url = flags.dataset;
|
|
178
|
+
} else if (flags.dataset) {
|
|
179
|
+
// Local file — upload first
|
|
180
|
+
const { createReadStream, statSync } = await import('fs');
|
|
181
|
+
if (!existsSync(flags.dataset)) {
|
|
182
|
+
console.error(chalk.red(`\n ✗ Dataset file not found: ${flags.dataset}\n`));
|
|
183
|
+
process.exitCode = 1;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
console.log(chalk.dim(`\n Uploading dataset ${flags.dataset}…`));
|
|
187
|
+
const { readFileSync: readDs } = await import('fs');
|
|
188
|
+
const fileData = readDs(flags.dataset);
|
|
189
|
+
const form = new FormData();
|
|
190
|
+
form.append('file', new Blob([fileData]), flags.dataset.split('/').pop() || 'dataset.jsonl');
|
|
191
|
+
const baseUrl = config.baseUrl.replace(/\/v1\/?$/, '');
|
|
192
|
+
const uploadRes = await fetch(`${baseUrl}/v1/uploads`, {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
body: form,
|
|
195
|
+
headers: { Authorization: `Bearer ${config.apiKey}` },
|
|
196
|
+
});
|
|
197
|
+
if (!uploadRes.ok) {
|
|
198
|
+
const text = await uploadRes.text().catch(() => '');
|
|
199
|
+
console.error(chalk.red(`\n ✗ Dataset upload failed: ${uploadRes.status}${text ? ` — ${text}` : ''}\n`));
|
|
200
|
+
process.exitCode = 1;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const uploadResp = await uploadRes.json();
|
|
204
|
+
input.dataset_file_id = uploadResp.upload_id;
|
|
205
|
+
console.log(chalk.dim(` Uploaded: ${uploadResp.upload_id}`));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (flags.gpuType) input.gpu_type = flags.gpuType;
|
|
209
|
+
|
|
210
|
+
const rcptId = generateReceiptId();
|
|
211
|
+
const maxRuntime = flags.maxRuntime ?? 240;
|
|
212
|
+
|
|
213
|
+
console.log(chalk.bold('\n⚡ Starting LoRA training\n'));
|
|
214
|
+
console.log(` ${chalk.bold('Base model:')} ${flags.baseModel}`);
|
|
215
|
+
console.log(` ${chalk.bold('Preset:')} ${input.config_preset}`);
|
|
216
|
+
console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost}`);
|
|
217
|
+
console.log(` ${chalk.bold('Max runtime:')} ${maxRuntime} min\n`);
|
|
218
|
+
|
|
219
|
+
let job;
|
|
220
|
+
try {
|
|
221
|
+
job = await callApi('/jobs', {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
apiKey: config.apiKey,
|
|
224
|
+
baseUrl: config.baseUrl,
|
|
225
|
+
body: {
|
|
226
|
+
type: 'train.lora',
|
|
227
|
+
input,
|
|
228
|
+
policy: { max_cost: flags.maxCost, max_runtime_minutes: maxRuntime, tier: flags.tier },
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
} catch (err) {
|
|
232
|
+
console.error(chalk.red(`\n ✗ Failed to submit job: ${err.message}\n`));
|
|
233
|
+
process.exitCode = 1;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
addReceipt({ id: rcptId, type: 'train.lora', job_id: job.job_id, started_at: Date.now() });
|
|
238
|
+
console.log(` ${chalk.bold('Job ID:')} ${job.job_id}`);
|
|
239
|
+
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
240
|
+
console.log(chalk.dim('\n Polling for completion — Ctrl+C to detach (GPU keeps running)\n'));
|
|
241
|
+
|
|
242
|
+
// Poll until complete
|
|
243
|
+
const startMs = Date.now();
|
|
244
|
+
const maxMs = maxRuntime * 60 * 1000;
|
|
245
|
+
while (Date.now() - startMs < maxMs) {
|
|
246
|
+
await new Promise(r => setTimeout(r, 15_000));
|
|
247
|
+
let detail;
|
|
248
|
+
try {
|
|
249
|
+
detail = await callApi(`/jobs/${job.job_id}`, {
|
|
250
|
+
apiKey: config.apiKey,
|
|
251
|
+
baseUrl: config.baseUrl,
|
|
252
|
+
});
|
|
253
|
+
} catch { continue; }
|
|
254
|
+
process.stdout.write(`\r Status: ${detail.status} elapsed: ${Math.floor((Date.now() - startMs) / 1000)}s `);
|
|
255
|
+
if (detail.status === 'completed') {
|
|
256
|
+
const out = detail.output || {};
|
|
257
|
+
console.log(chalk.green('\n\n ✓ Training complete\n'));
|
|
258
|
+
if (out.adapter_url) console.log(` ${chalk.bold('Adapter:')} ${out.adapter_url}`);
|
|
259
|
+
if (out.checkpoint_url) console.log(` ${chalk.bold('Checkpoint:')} ${out.checkpoint_url}`);
|
|
260
|
+
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}\n`);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (detail.status === 'failed') {
|
|
264
|
+
console.error(chalk.red(`\n\n ✗ Training failed: ${detail.error_code || ''} — ${detail.error_message || ''}\n`));
|
|
265
|
+
process.exitCode = 1;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
console.error(chalk.yellow('\n\n Training still running — detached. Check status:\n'));
|
|
270
|
+
console.error(chalk.dim(` badgr status\n`));
|
|
271
|
+
}
|
|
272
|
+
|
|
90
273
|
export async function trainCommand(config, args, chalk) {
|
|
274
|
+
// Route subcommands
|
|
275
|
+
const sub = args[0];
|
|
276
|
+
if (sub === 'lora') return trainLoraCommand(config, args.slice(1), chalk);
|
|
277
|
+
|
|
91
278
|
const { configFile, flags } = parseTrainArgs(args);
|
|
92
279
|
|
|
93
280
|
if (!configFile) {
|
|
94
281
|
console.error(chalk.red('\n Usage: badgr train config.yaml\n'));
|
|
282
|
+
console.error(chalk.dim(' Productized LoRA: badgr train lora --base-model MODEL --dataset FILE --max-cost N\n'));
|
|
95
283
|
console.error(chalk.dim(' Runs LoRA / fine-tuning on GPU and streams logs.\n'));
|
|
96
284
|
process.exitCode = 1;
|
|
97
285
|
return;
|
|
@@ -158,12 +346,27 @@ export async function trainCommand(config, args, chalk) {
|
|
|
158
346
|
console.log(` ${chalk.bold('Max runtime:')} ${runtimeLabel}`);
|
|
159
347
|
if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
|
|
160
348
|
console.log();
|
|
349
|
+
|
|
350
|
+
// Block before provisioning: TRAINING_IMAGES[framework] and the container
|
|
351
|
+
// command must be a matched pair, or the job burns GPU time and then fails
|
|
352
|
+
// (e.g. an axolotl `train` binary that doesn't exist in the unsloth image).
|
|
353
|
+
const trainCmd = TRAINING_COMMANDS[framework];
|
|
354
|
+
if (!trainCmd) {
|
|
355
|
+
console.error(chalk.red(` ✗ No runnable command for framework '${framework}' — refusing to provision a GPU that will fail.\n`));
|
|
356
|
+
console.error(chalk.dim(` '${image}' does not have a known working entrypoint for this config yet.`));
|
|
357
|
+
console.error(chalk.dim(` Options:`));
|
|
358
|
+
console.error(chalk.dim(` --framework axolotl force Axolotl (it has native Unsloth-optimization support via config keys)`));
|
|
359
|
+
console.error(chalk.dim(` badgr train lora ... use the productized LoRA path instead\n`));
|
|
360
|
+
process.exitCode = 1;
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
161
364
|
process.stdout.write(chalk.dim(' Finding suitable capacity...\n'));
|
|
162
365
|
|
|
163
366
|
function buildBody(tierOverride) {
|
|
164
367
|
return {
|
|
165
368
|
image,
|
|
166
|
-
command: ['sh', '-c',
|
|
369
|
+
command: ['sh', '-c', trainCmd],
|
|
167
370
|
gpu,
|
|
168
371
|
gpu_count: 1,
|
|
169
372
|
...(flags.region ? { region: flags.region.toUpperCase() } : {}),
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* --dry-run for the productized runner commands (badgr train lora, badgr comfyui batch).
|
|
3
|
+
*
|
|
4
|
+
* Both must preview the request (preset/workflow, GPU, cost) without calling the API,
|
|
5
|
+
* uploading files, or requiring an API key — mirroring the existing `badgr run --dry-run`
|
|
6
|
+
* and `badgr up --dry-run` behavior.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
import { trainLoraCommand, parseTrainLoraArgs } from '../src/commands/train.js';
|
|
10
|
+
import { comfyBatchCommand, parseComfyBatchArgs } from '../src/commands/comfyui.js';
|
|
11
|
+
|
|
12
|
+
vi.mock('../src/api.js', () => ({
|
|
13
|
+
callApi: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('../src/store.js', () => ({
|
|
17
|
+
addReceipt: vi.fn(),
|
|
18
|
+
generateReceiptId: vi.fn(() => 'rcpt-dry-001'),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock('fs', async (importOriginal) => {
|
|
22
|
+
const actual = await importOriginal();
|
|
23
|
+
return {
|
|
24
|
+
...actual,
|
|
25
|
+
readFileSync: vi.fn(),
|
|
26
|
+
existsSync: vi.fn(),
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
import * as api from '../src/api.js';
|
|
31
|
+
import * as store from '../src/store.js';
|
|
32
|
+
import * as fs from 'fs';
|
|
33
|
+
|
|
34
|
+
const chalk = {
|
|
35
|
+
bold: s => s, dim: s => s, red: s => s, yellow: s => s, green: s => s, cyan: s => s,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
process.exitCode = undefined;
|
|
42
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
43
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
44
|
+
vi.clearAllMocks();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
vi.restoreAllMocks();
|
|
49
|
+
process.exitCode = undefined;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('parseTrainLoraArgs', () => {
|
|
53
|
+
it('parses --dry-run', () => {
|
|
54
|
+
const flags = parseTrainLoraArgs(['--base-model', 'x', '--dry-run']);
|
|
55
|
+
expect(flags.dryRun).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('trainLoraCommand --dry-run', () => {
|
|
60
|
+
it('previews the job without calling the API or requiring an API key', async () => {
|
|
61
|
+
await trainLoraCommand({ apiKey: null, baseUrl: 'https://api.test/v1' }, [
|
|
62
|
+
'--base-model', 'mistralai/Mistral-7B-v0.1',
|
|
63
|
+
'--dataset', 'https://example.com/data.jsonl',
|
|
64
|
+
'--dry-run',
|
|
65
|
+
], chalk);
|
|
66
|
+
|
|
67
|
+
expect(api.callApi).not.toHaveBeenCalled();
|
|
68
|
+
expect(store.addReceipt).not.toHaveBeenCalled();
|
|
69
|
+
expect(process.exitCode).toBeFalsy();
|
|
70
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
71
|
+
expect(logged).toContain('Dry run');
|
|
72
|
+
expect(logged).toContain('mistralai/Mistral-7B-v0.1');
|
|
73
|
+
expect(logged).toContain('RTX_4090'); // default 'small' preset GPU
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('does not upload a local dataset file during --dry-run', async () => {
|
|
77
|
+
fs.existsSync.mockReturnValue(true);
|
|
78
|
+
fs.readFileSync.mockReturnValue('dummy');
|
|
79
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ upload_id: 'up_x' }) });
|
|
80
|
+
|
|
81
|
+
await trainLoraCommand(config, [
|
|
82
|
+
'--base-model', 'mistralai/Mistral-7B-v0.1',
|
|
83
|
+
'--dataset', './local.jsonl',
|
|
84
|
+
'--dry-run',
|
|
85
|
+
], chalk);
|
|
86
|
+
|
|
87
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
88
|
+
fetchSpy.mockRestore();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('still requires --base-model even with --dry-run', async () => {
|
|
92
|
+
await trainLoraCommand(config, ['--dry-run'], chalk);
|
|
93
|
+
expect(process.exitCode).toBe(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('flags an unknown preset instead of silently accepting it', async () => {
|
|
97
|
+
await trainLoraCommand(config, [
|
|
98
|
+
'--base-model', 'mistralai/Mistral-7B-v0.1',
|
|
99
|
+
'--preset', 'huge',
|
|
100
|
+
'--dry-run',
|
|
101
|
+
], chalk);
|
|
102
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
103
|
+
expect(logged).toContain('unknown');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('parseComfyBatchArgs', () => {
|
|
108
|
+
it('parses --dry-run', () => {
|
|
109
|
+
const flags = parseComfyBatchArgs(['--workflow', 'sdxl-basic', '--dry-run']);
|
|
110
|
+
expect(flags.dryRun).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('comfyBatchCommand --dry-run', () => {
|
|
115
|
+
it('previews the batch without calling the API or requiring an API key', async () => {
|
|
116
|
+
await comfyBatchCommand({ apiKey: null, baseUrl: 'https://api.test/v1' }, [
|
|
117
|
+
'--workflow', 'sdxl-basic',
|
|
118
|
+
'--prompt', 'a cat on a beach',
|
|
119
|
+
'--dry-run',
|
|
120
|
+
], chalk);
|
|
121
|
+
|
|
122
|
+
expect(api.callApi).not.toHaveBeenCalled();
|
|
123
|
+
expect(store.addReceipt).not.toHaveBeenCalled();
|
|
124
|
+
expect(process.exitCode).toBeFalsy();
|
|
125
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
126
|
+
expect(logged).toContain('Dry run');
|
|
127
|
+
expect(logged).toContain('sdxl-basic');
|
|
128
|
+
expect(logged).toContain('RTX_4090');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('warns when the prompt count exceeds the 20-prompt cap', async () => {
|
|
132
|
+
const flags = { workflow: 'sdxl-basic', inlinePrompts: Array(21).fill('x') };
|
|
133
|
+
await comfyBatchCommand({ apiKey: null, baseUrl: 'https://api.test/v1' }, [
|
|
134
|
+
'--workflow', 'sdxl-basic',
|
|
135
|
+
...Array(21).fill(['--prompt', 'x']).flat(),
|
|
136
|
+
'--dry-run',
|
|
137
|
+
], chalk);
|
|
138
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
139
|
+
expect(logged).toContain('exceeds the 20-prompt limit');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Productized runner tests
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - Blessed vLLM aliases expand to full model ID + correct GPU
|
|
6
|
+
* - serveCommand sends full model ID to API when alias is given
|
|
7
|
+
* - BLESSED_VLLM_MODELS has required fields
|
|
8
|
+
* - BLESSED_COMFY_WORKFLOWS has required fields
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
11
|
+
import { parseServeArgs, serveCommand } from '../src/commands/serve.js';
|
|
12
|
+
import { BLESSED_VLLM_MODELS, BLESSED_COMFY_WORKFLOWS } from '../src/catalog.js';
|
|
13
|
+
|
|
14
|
+
// ── Module mocks ──────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
vi.mock('../src/api.js', () => ({
|
|
17
|
+
callApi: vi.fn(),
|
|
18
|
+
terminateDeployment: vi.fn().mockResolvedValue({}),
|
|
19
|
+
listDeployments: vi.fn().mockResolvedValue({ deployments: [], count: 0 }),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('../src/store.js', () => ({
|
|
23
|
+
addDeployment: vi.fn(),
|
|
24
|
+
addReceipt: vi.fn(),
|
|
25
|
+
updateReceipt: vi.fn(),
|
|
26
|
+
generateReceiptId: vi.fn(() => 'rcpt-prod-001'),
|
|
27
|
+
generateDeploymentId: vi.fn(() => 'dep-prod-001'),
|
|
28
|
+
listDeployments: vi.fn(() => []),
|
|
29
|
+
listReceipts: vi.fn(() => []),
|
|
30
|
+
findDeployment: vi.fn(() => null),
|
|
31
|
+
updateDeployment: vi.fn(),
|
|
32
|
+
removeDeployment: vi.fn(),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock('../src/fallback.js', () => ({
|
|
36
|
+
normalizeTier: vi.fn(t => t || null),
|
|
37
|
+
callWithFallback: vi.fn(),
|
|
38
|
+
HIGH_RATE_THRESHOLD: 2.0,
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
vi.mock('../src/config.js', () => ({
|
|
42
|
+
requireApiKey: vi.fn(),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
vi.mock('../src/errors.js', () => ({
|
|
46
|
+
formatCliError: vi.fn((code, ctx) => `Error: ${code}`),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
import * as fallback from '../src/fallback.js';
|
|
50
|
+
|
|
51
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
|
|
54
|
+
|
|
55
|
+
const chalk = {
|
|
56
|
+
bold: s => s,
|
|
57
|
+
dim: s => s,
|
|
58
|
+
red: s => s,
|
|
59
|
+
yellow: s => s,
|
|
60
|
+
green: s => s,
|
|
61
|
+
cyan: s => s,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function makeServeDep(overrides = {}) {
|
|
65
|
+
return {
|
|
66
|
+
deployment_id: 'dep-prod-001',
|
|
67
|
+
status: 'running',
|
|
68
|
+
gpu_type: 'RTX_4090',
|
|
69
|
+
gpu_count: 1,
|
|
70
|
+
cost_per_hour: 1.20,
|
|
71
|
+
provider: 'runpod',
|
|
72
|
+
receipt_id: 'rcpt-prod-001',
|
|
73
|
+
tier: '1',
|
|
74
|
+
endpoint_url: 'https://dep-prod-001.aibadgr.com/v1',
|
|
75
|
+
...overrides,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
vi.clearAllMocks();
|
|
81
|
+
process.exitCode = undefined;
|
|
82
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
83
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
84
|
+
vi.spyOn(process.stdout, 'write').mockImplementation(() => {});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ── Catalog shape tests ───────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
describe('BLESSED_VLLM_MODELS catalog', () => {
|
|
90
|
+
it('has qwen-7b, llama-8b, qwen-coder-7b', () => {
|
|
91
|
+
expect(Object.keys(BLESSED_VLLM_MODELS)).toEqual(
|
|
92
|
+
expect.arrayContaining(['qwen-7b', 'llama-8b', 'qwen-coder-7b'])
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('each entry has model_id, gpu_type, image, health_path', () => {
|
|
97
|
+
for (const [alias, spec] of Object.entries(BLESSED_VLLM_MODELS)) {
|
|
98
|
+
expect(spec.model_id, `${alias}.model_id`).toBeTruthy();
|
|
99
|
+
expect(spec.gpu_type, `${alias}.gpu_type`).toBeTruthy();
|
|
100
|
+
expect(spec.image, `${alias}.image`).toBeTruthy();
|
|
101
|
+
expect(spec.health_path, `${alias}.health_path`).toBeTruthy();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('qwen-7b resolves to Qwen2.5-7B-Instruct on RTX_4090', () => {
|
|
106
|
+
const spec = BLESSED_VLLM_MODELS['qwen-7b'];
|
|
107
|
+
expect(spec.model_id).toBe('Qwen/Qwen2.5-7B-Instruct');
|
|
108
|
+
expect(spec.gpu_type).toBe('RTX_4090');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('llama-8b resolves to Llama-3.1-8B-Instruct on RTX_4090', () => {
|
|
112
|
+
const spec = BLESSED_VLLM_MODELS['llama-8b'];
|
|
113
|
+
expect(spec.model_id).toBe('meta-llama/Llama-3.1-8B-Instruct');
|
|
114
|
+
expect(spec.gpu_type).toBe('RTX_4090');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('qwen-coder-7b resolves to Qwen2.5-Coder-7B-Instruct on RTX_4090', () => {
|
|
118
|
+
const spec = BLESSED_VLLM_MODELS['qwen-coder-7b'];
|
|
119
|
+
expect(spec.model_id).toBe('Qwen/Qwen2.5-Coder-7B-Instruct');
|
|
120
|
+
expect(spec.gpu_type).toBe('RTX_4090');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('BLESSED_COMFY_WORKFLOWS catalog', () => {
|
|
125
|
+
it('has sdxl-basic', () => {
|
|
126
|
+
expect(BLESSED_COMFY_WORKFLOWS['sdxl-basic']).toBeDefined();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('sdxl-basic has gpu_type and output_type', () => {
|
|
130
|
+
const wf = BLESSED_COMFY_WORKFLOWS['sdxl-basic'];
|
|
131
|
+
expect(wf.gpu_type).toBe('RTX_4090');
|
|
132
|
+
expect(wf.output_type).toBe('images');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── parseServeArgs alias recognition ─────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
describe('parseServeArgs — alias handling', () => {
|
|
139
|
+
it('passes alias through as positional model', () => {
|
|
140
|
+
const { model } = parseServeArgs(['qwen-7b', '--max-cost', '10']);
|
|
141
|
+
expect(model).toBe('qwen-7b');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('alias is distinct from full model ID in parseServeArgs', () => {
|
|
145
|
+
const { model: alias } = parseServeArgs(['qwen-7b']);
|
|
146
|
+
const { model: full } = parseServeArgs(['Qwen/Qwen2.5-7B-Instruct']);
|
|
147
|
+
expect(alias).toBe('qwen-7b');
|
|
148
|
+
expect(full).toBe('Qwen/Qwen2.5-7B-Instruct');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ── serveCommand alias expansion ──────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
describe('serveCommand — alias expansion', () => {
|
|
155
|
+
it('sends full model_id to API when alias qwen-7b is used', async () => {
|
|
156
|
+
fallback.callWithFallback.mockResolvedValue(makeServeDep({
|
|
157
|
+
model: 'Qwen/Qwen2.5-7B-Instruct',
|
|
158
|
+
}));
|
|
159
|
+
// Mock fetch for health check (immediate pass)
|
|
160
|
+
global.fetch = vi.fn().mockResolvedValue({ ok: true });
|
|
161
|
+
|
|
162
|
+
await serveCommand(config, ['qwen-7b', '--max-cost', '10', '--no-wait'], chalk);
|
|
163
|
+
|
|
164
|
+
expect(fallback.callWithFallback).toHaveBeenCalled();
|
|
165
|
+
const bodyFn = fallback.callWithFallback.mock.calls[0][2];
|
|
166
|
+
const body = bodyFn(null);
|
|
167
|
+
expect(body.model).toBe('Qwen/Qwen2.5-7B-Instruct');
|
|
168
|
+
expect(body.gpu).toBe('RTX_4090');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('sends full model_id to API when alias llama-8b is used', async () => {
|
|
172
|
+
fallback.callWithFallback.mockResolvedValue(makeServeDep({
|
|
173
|
+
model: 'meta-llama/Llama-3.1-8B-Instruct',
|
|
174
|
+
}));
|
|
175
|
+
global.fetch = vi.fn().mockResolvedValue({ ok: true });
|
|
176
|
+
|
|
177
|
+
await serveCommand(config, ['llama-8b', '--max-cost', '10', '--no-wait'], chalk);
|
|
178
|
+
|
|
179
|
+
const bodyFn = fallback.callWithFallback.mock.calls[0][2];
|
|
180
|
+
const body = bodyFn(null);
|
|
181
|
+
expect(body.model).toBe('meta-llama/Llama-3.1-8B-Instruct');
|
|
182
|
+
expect(body.gpu).toBe('RTX_4090');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('sends full model_id to API when alias qwen-coder-7b is used', async () => {
|
|
186
|
+
fallback.callWithFallback.mockResolvedValue(makeServeDep({
|
|
187
|
+
model: 'Qwen/Qwen2.5-Coder-7B-Instruct',
|
|
188
|
+
}));
|
|
189
|
+
global.fetch = vi.fn().mockResolvedValue({ ok: true });
|
|
190
|
+
|
|
191
|
+
await serveCommand(config, ['qwen-coder-7b', '--max-cost', '5', '--no-wait'], chalk);
|
|
192
|
+
|
|
193
|
+
const bodyFn = fallback.callWithFallback.mock.calls[0][2];
|
|
194
|
+
const body = bodyFn(null);
|
|
195
|
+
expect(body.model).toBe('Qwen/Qwen2.5-Coder-7B-Instruct');
|
|
196
|
+
expect(body.gpu).toBe('RTX_4090');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('passes full model ID unchanged (no alias lookup)', async () => {
|
|
200
|
+
fallback.callWithFallback.mockResolvedValue(makeServeDep({
|
|
201
|
+
model: 'meta-llama/Llama-3.1-70B-Instruct',
|
|
202
|
+
gpu_type: 'H100',
|
|
203
|
+
}));
|
|
204
|
+
global.fetch = vi.fn().mockResolvedValue({ ok: true });
|
|
205
|
+
|
|
206
|
+
await serveCommand(config, ['meta-llama/Llama-3.1-70B-Instruct', '--max-cost', '20', '--gpu', 'H100', '--no-wait'], chalk);
|
|
207
|
+
|
|
208
|
+
const bodyFn = fallback.callWithFallback.mock.calls[0][2];
|
|
209
|
+
const body = bodyFn(null);
|
|
210
|
+
expect(body.model).toBe('meta-llama/Llama-3.1-70B-Instruct');
|
|
211
|
+
expect(body.gpu).toBe('H100');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('rejects --max-cost missing for alias (same as non-alias)', async () => {
|
|
215
|
+
await serveCommand(config, ['qwen-7b'], chalk);
|
|
216
|
+
expect(process.exitCode).toBe(1);
|
|
217
|
+
expect(fallback.callWithFallback).not.toHaveBeenCalled();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('alias auto-sets RTX_4090 but --gpu flag overrides it', async () => {
|
|
221
|
+
fallback.callWithFallback.mockResolvedValue(makeServeDep({ gpu_type: 'L40S' }));
|
|
222
|
+
global.fetch = vi.fn().mockResolvedValue({ ok: true });
|
|
223
|
+
|
|
224
|
+
await serveCommand(config, ['qwen-7b', '--max-cost', '10', '--gpu', 'L40S', '--no-wait'], chalk);
|
|
225
|
+
|
|
226
|
+
const bodyFn = fallback.callWithFallback.mock.calls[0][2];
|
|
227
|
+
const body = bodyFn(null);
|
|
228
|
+
expect(body.gpu).toBe('L40S');
|
|
229
|
+
});
|
|
230
|
+
});
|