badgr-cli 1.0.41 → 1.0.42
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/package.json +1 -1
- package/src/catalog.js +38 -0
- package/src/commands/comfyui.js +132 -0
- package/src/commands/serve.js +19 -9
- package/src/commands/train.js +129 -0
- package/tests/productized-runners.test.js +230 -0
package/package.json
CHANGED
package/src/catalog.js
CHANGED
|
@@ -442,6 +442,44 @@ export const TEMPLATES = [
|
|
|
442
442
|
|
|
443
443
|
export const TEMPLATE_MAP = Object.fromEntries(TEMPLATES.map(t => [t.name, t]));
|
|
444
444
|
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// Productized runner catalogs
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
/** Blessed vLLM model aliases. `badgr serve qwen-7b` expands to the full model ID. */
|
|
450
|
+
export const BLESSED_VLLM_MODELS = {
|
|
451
|
+
'qwen-7b': {
|
|
452
|
+
model_id: 'Qwen/Qwen2.5-7B-Instruct',
|
|
453
|
+
gpu_type: 'RTX_4090',
|
|
454
|
+
image: 'vllm/vllm-openai:latest',
|
|
455
|
+
health_path: '/v1/models',
|
|
456
|
+
description: 'Qwen 2.5 7B Instruct — fast, multilingual',
|
|
457
|
+
},
|
|
458
|
+
'llama-8b': {
|
|
459
|
+
model_id: 'meta-llama/Llama-3.1-8B-Instruct',
|
|
460
|
+
gpu_type: 'RTX_4090',
|
|
461
|
+
image: 'vllm/vllm-openai:latest',
|
|
462
|
+
health_path: '/v1/models',
|
|
463
|
+
description: 'Llama 3.1 8B Instruct — Meta flagship 8B',
|
|
464
|
+
},
|
|
465
|
+
'qwen-coder-7b': {
|
|
466
|
+
model_id: 'Qwen/Qwen2.5-Coder-7B-Instruct',
|
|
467
|
+
gpu_type: 'RTX_4090',
|
|
468
|
+
image: 'vllm/vllm-openai:latest',
|
|
469
|
+
health_path: '/v1/models',
|
|
470
|
+
description: 'Qwen 2.5 Coder 7B — code generation specialist',
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
/** Blessed ComfyUI workflows accepted by `POST /v1/jobs` comfy.batch. */
|
|
475
|
+
export const BLESSED_COMFY_WORKFLOWS = {
|
|
476
|
+
'sdxl-basic': {
|
|
477
|
+
description: 'SDXL text-to-image with default sampler settings',
|
|
478
|
+
gpu_type: 'RTX_4090',
|
|
479
|
+
output_type: 'images',
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
|
|
445
483
|
/**
|
|
446
484
|
* Build the args array passed to serveCommand / runCommand.
|
|
447
485
|
* Template defaults are applied first; CLI overrides win.
|
package/src/commands/comfyui.js
CHANGED
|
@@ -110,11 +110,143 @@ async function validateComfyNodes(endpointUrl, nodeList, chalk) {
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// badgr comfyui batch — productized batch via POST /v1/jobs comfy.batch
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
export function parseComfyBatchArgs(args) {
|
|
118
|
+
const flags = {};
|
|
119
|
+
let i = 0;
|
|
120
|
+
while (i < args.length) {
|
|
121
|
+
const a = args[i];
|
|
122
|
+
if (a === '--workflow') { flags.workflow = args[++i]; i++; continue; }
|
|
123
|
+
if (a === '--prompts') { flags.prompts = args[++i]; i++; continue; }
|
|
124
|
+
if (a === '--prompt') { if (!flags.inlinePrompts) flags.inlinePrompts = []; flags.inlinePrompts.push(args[++i]); i++; continue; }
|
|
125
|
+
if (a === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
|
|
126
|
+
if (a === '--max-runtime') { flags.maxRuntime = parseFloat(args[++i]); i++; continue; }
|
|
127
|
+
if (a === '--tier') { flags.tier = args[++i]; i++; continue; }
|
|
128
|
+
if (a === '--gpu-type') { flags.gpuType = args[++i]; i++; continue; }
|
|
129
|
+
i++;
|
|
130
|
+
}
|
|
131
|
+
return flags;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function comfyBatchCommand(config, args, chalk) {
|
|
135
|
+
const { readFileSync, existsSync } = await import('fs');
|
|
136
|
+
const { callApi } = await import('../api.js');
|
|
137
|
+
const { addReceipt, generateReceiptId } = await import('../store.js');
|
|
138
|
+
const flags = parseComfyBatchArgs(args);
|
|
139
|
+
|
|
140
|
+
if (!flags.workflow) {
|
|
141
|
+
console.error(chalk.red('\n Usage: badgr comfyui batch --workflow sdxl-basic --prompts prompts.txt --max-cost 10\n'));
|
|
142
|
+
console.error(chalk.dim(' Runs a batch of prompts through a blessed ComfyUI workflow and returns image URLs.\n'));
|
|
143
|
+
console.error(chalk.dim(' Blessed workflows: sdxl-basic\n'));
|
|
144
|
+
process.exitCode = 1;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!flags.maxCost) {
|
|
149
|
+
console.error(chalk.red('\n ✗ --max-cost is required.\n'));
|
|
150
|
+
process.exitCode = 1;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
requireApiKey(config);
|
|
155
|
+
|
|
156
|
+
// Collect prompts from file or --prompt flags
|
|
157
|
+
let prompts = flags.inlinePrompts || [];
|
|
158
|
+
if (flags.prompts) {
|
|
159
|
+
if (!existsSync(flags.prompts)) {
|
|
160
|
+
console.error(chalk.red(`\n ✗ Prompts file not found: ${flags.prompts}\n`));
|
|
161
|
+
process.exitCode = 1;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const lines = readFileSync(flags.prompts, 'utf8')
|
|
165
|
+
.split('\n')
|
|
166
|
+
.map(l => l.trim())
|
|
167
|
+
.filter(Boolean);
|
|
168
|
+
prompts = prompts.concat(lines);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (prompts.length === 0) {
|
|
172
|
+
console.error(chalk.red('\n ✗ No prompts provided. Use --prompts file.txt or --prompt "text"\n'));
|
|
173
|
+
process.exitCode = 1;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (prompts.length > 20) {
|
|
178
|
+
console.error(chalk.red(`\n ✗ Max 20 prompts per batch (got ${prompts.length})\n`));
|
|
179
|
+
process.exitCode = 1;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const input = { workflow_id: flags.workflow, prompts };
|
|
184
|
+
if (flags.gpuType) input.gpu_type = flags.gpuType;
|
|
185
|
+
|
|
186
|
+
const rcptId = generateReceiptId();
|
|
187
|
+
const maxRuntime = flags.maxRuntime ?? 60;
|
|
188
|
+
|
|
189
|
+
console.log(chalk.bold('\n⚡ Starting ComfyUI batch\n'));
|
|
190
|
+
console.log(` ${chalk.bold('Workflow:')} ${flags.workflow}`);
|
|
191
|
+
console.log(` ${chalk.bold('Prompts:')} ${prompts.length}`);
|
|
192
|
+
console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost}`);
|
|
193
|
+
console.log(` ${chalk.bold('Max runtime:')} ${maxRuntime} min\n`);
|
|
194
|
+
|
|
195
|
+
let job;
|
|
196
|
+
try {
|
|
197
|
+
job = await callApi(config, 'POST', '/v1/jobs', {
|
|
198
|
+
type: 'comfy.batch',
|
|
199
|
+
input,
|
|
200
|
+
policy: { max_cost: flags.maxCost, max_runtime_minutes: maxRuntime, tier: flags.tier },
|
|
201
|
+
});
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.error(chalk.red(`\n ✗ Failed to submit job: ${err.message}\n`));
|
|
204
|
+
process.exitCode = 1;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
addReceipt({ id: rcptId, type: 'comfy.batch', job_id: job.job_id, started_at: Date.now() });
|
|
209
|
+
console.log(` ${chalk.bold('Job ID:')} ${job.job_id}`);
|
|
210
|
+
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
211
|
+
console.log(chalk.dim('\n Polling for completion…\n'));
|
|
212
|
+
|
|
213
|
+
const startMs = Date.now();
|
|
214
|
+
const maxMs = maxRuntime * 60 * 1000;
|
|
215
|
+
while (Date.now() - startMs < maxMs) {
|
|
216
|
+
await new Promise(r => setTimeout(r, 15_000));
|
|
217
|
+
let detail;
|
|
218
|
+
try { detail = await callApi(config, 'GET', `/v1/jobs/${job.job_id}`); } catch { continue; }
|
|
219
|
+
process.stdout.write(`\r Status: ${detail.status} elapsed: ${Math.floor((Date.now() - startMs) / 1000)}s `);
|
|
220
|
+
if (detail.status === 'completed') {
|
|
221
|
+
const out = detail.output || {};
|
|
222
|
+
console.log(chalk.green('\n\n ✓ Batch complete\n'));
|
|
223
|
+
if (out.image_urls && out.image_urls.length > 0) {
|
|
224
|
+
console.log(` ${chalk.bold('Images (${out.image_urls.length}):')}`);
|
|
225
|
+
out.image_urls.forEach((url, i) => console.log(` ${i + 1}. ${url}`));
|
|
226
|
+
}
|
|
227
|
+
console.log(`\n ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}\n`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (detail.status === 'failed') {
|
|
231
|
+
console.error(chalk.red(`\n\n ✗ Batch failed: ${detail.error_code || ''} — ${detail.error_message || ''}\n`));
|
|
232
|
+
process.exitCode = 1;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
console.error(chalk.yellow('\n\n Batch still running — detached. Check status:\n'));
|
|
237
|
+
console.error(chalk.dim(` badgr status\n`));
|
|
238
|
+
}
|
|
239
|
+
|
|
113
240
|
export async function comfyuiCommand(config, args, chalk) {
|
|
241
|
+
// Route subcommands
|
|
242
|
+
const sub = args[0];
|
|
243
|
+
if (sub === 'batch') return comfyBatchCommand(config, args.slice(1), chalk);
|
|
244
|
+
|
|
114
245
|
const { workflow, flags } = parseComfyuiArgs(args);
|
|
115
246
|
|
|
116
247
|
if (!workflow) {
|
|
117
248
|
console.error(chalk.red('\n Usage: badgr comfyui run workflow.json\n'));
|
|
249
|
+
console.error(chalk.dim(' Batch mode: badgr comfyui batch --workflow sdxl-basic --prompts prompts.txt --max-cost 10\n'));
|
|
118
250
|
console.error(chalk.dim(' Launches ComfyUI, queues your workflow, and returns the URL.\n'));
|
|
119
251
|
process.exitCode = 1;
|
|
120
252
|
return;
|
package/src/commands/serve.js
CHANGED
|
@@ -3,7 +3,7 @@ import { callApi, listDeployments } from '../api.js';
|
|
|
3
3
|
import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../store.js';
|
|
4
4
|
import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
|
|
5
5
|
import { formatCliError } from '../errors.js';
|
|
6
|
-
import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides } from '../catalog.js';
|
|
6
|
+
import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides, BLESSED_VLLM_MODELS } from '../catalog.js';
|
|
7
7
|
|
|
8
8
|
const LLAMA_CPP_IMAGE = 'michaelmanleyx/llama-cpp:server-cuda';
|
|
9
9
|
|
|
@@ -221,6 +221,10 @@ export async function serveCommand(config, args, chalk) {
|
|
|
221
221
|
const customImage = flags.image || null;
|
|
222
222
|
const isLlamaCpp = flags.runtime === 'llama.cpp';
|
|
223
223
|
|
|
224
|
+
// Expand blessed alias (qwen-7b, llama-8b, qwen-coder-7b) to full model ID + GPU.
|
|
225
|
+
const vllmAlias = model && !customImage && !isLlamaCpp ? BLESSED_VLLM_MODELS[model] : null;
|
|
226
|
+
const effectiveModel = vllmAlias ? vllmAlias.model_id : model;
|
|
227
|
+
|
|
224
228
|
// Detect flags that ended up as positional args due to broken shell line continuation
|
|
225
229
|
// (e.g. `\ ` with a trailing space instead of `\<newline>`).
|
|
226
230
|
// After parseServeArgs, only the model name should be in positional. Any extra token
|
|
@@ -293,7 +297,9 @@ export async function serveCommand(config, args, chalk) {
|
|
|
293
297
|
|
|
294
298
|
const envObj = parseEnvFlag(flags.env);
|
|
295
299
|
|
|
296
|
-
const gpu = flags.gpu
|
|
300
|
+
const gpu = flags.gpu
|
|
301
|
+
? flags.gpu.toUpperCase().replace('-', '_')
|
|
302
|
+
: (vllmAlias ? vllmAlias.gpu_type : (customImage ? 'L40S' : 'AUTO'));
|
|
297
303
|
const gpuLabel = gpu === 'AUTO' ? 'auto' : gpu;
|
|
298
304
|
|
|
299
305
|
const effectiveTier = normalizeTier(flags.tier);
|
|
@@ -319,13 +325,17 @@ export async function serveCommand(config, args, chalk) {
|
|
|
319
325
|
if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
|
|
320
326
|
} else {
|
|
321
327
|
console.log(chalk.bold('\n⚡ Serving model\n'));
|
|
322
|
-
|
|
328
|
+
if (vllmAlias) {
|
|
329
|
+
console.log(` ${chalk.bold('Alias:')} ${model} ${chalk.dim(`→ ${effectiveModel}`)}`);
|
|
330
|
+
} else {
|
|
331
|
+
console.log(` ${chalk.bold('Model:')} ${effectiveModel}`);
|
|
332
|
+
}
|
|
323
333
|
console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
|
|
324
334
|
if (flags.task) console.log(` ${chalk.bold('Task:')} ${flags.task}`);
|
|
325
335
|
if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
|
|
326
336
|
|
|
327
337
|
if (gpu === 'AUTO') {
|
|
328
|
-
const prof = _inferServeProfile(
|
|
338
|
+
const prof = _inferServeProfile(effectiveModel);
|
|
329
339
|
console.log();
|
|
330
340
|
console.log(` ${chalk.bold('Estimated workload:')} ${prof.label}`);
|
|
331
341
|
console.log(` ${chalk.bold('Estimated minimum VRAM:')} ${prof.vram}`);
|
|
@@ -348,7 +358,7 @@ export async function serveCommand(config, args, chalk) {
|
|
|
348
358
|
ACTIVE.has(d.status) &&
|
|
349
359
|
d.workload_type === 'endpoint' &&
|
|
350
360
|
(
|
|
351
|
-
(
|
|
361
|
+
(effectiveModel && d.model === effectiveModel) ||
|
|
352
362
|
(customImage && d.image === customImage) ||
|
|
353
363
|
(isLlamaCpp && d.image === LLAMA_CPP_IMAGE &&
|
|
354
364
|
d.env?.LLAMA_ARG_HF_REPO === flags.hfRepo && d.env?.LLAMA_ARG_HF_FILE === flags.hfFile)
|
|
@@ -381,7 +391,7 @@ export async function serveCommand(config, args, chalk) {
|
|
|
381
391
|
? { LLAMA_ARG_HF_REPO: flags.hfRepo, LLAMA_ARG_HF_FILE: flags.hfFile, ...envObj }
|
|
382
392
|
: envObj;
|
|
383
393
|
return {
|
|
384
|
-
...(
|
|
394
|
+
...(effectiveModel ? { model: effectiveModel } : {}),
|
|
385
395
|
...(isLlamaCpp ? { image: LLAMA_CPP_IMAGE } : customImage ? { image: customImage } : {}),
|
|
386
396
|
...(flags.task ? { task: flags.task } : {}),
|
|
387
397
|
gpu: gpuOverride || gpu,
|
|
@@ -424,7 +434,7 @@ export async function serveCommand(config, args, chalk) {
|
|
|
424
434
|
id: dep.deployment_id,
|
|
425
435
|
name: dep.name,
|
|
426
436
|
type: 'endpoint',
|
|
427
|
-
model: dep.model ||
|
|
437
|
+
model: dep.model || effectiveModel,
|
|
428
438
|
gpu: dep.gpu_type,
|
|
429
439
|
count: dep.gpu_count,
|
|
430
440
|
status: dep.status,
|
|
@@ -556,7 +566,7 @@ export async function serveCommand(config, args, chalk) {
|
|
|
556
566
|
console.log(` ${chalk.bold('HF Repo:')} ${flags.hfRepo}`);
|
|
557
567
|
console.log(` ${chalk.bold('HF File:')} ${flags.hfFile}`);
|
|
558
568
|
}
|
|
559
|
-
else if (dep.model ||
|
|
569
|
+
else if (dep.model || effectiveModel) console.log(` ${chalk.bold('Model:')} ${dep.model || effectiveModel}`);
|
|
560
570
|
if (customImage) console.log(` ${chalk.bold('Image:')} ${customImage}`);
|
|
561
571
|
console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
|
|
562
572
|
if (serveRate > 0) console.log(` ${chalk.bold('Rate:')} $${serveRate.toFixed(2)}/hr`);
|
|
@@ -574,7 +584,7 @@ export async function serveCommand(config, args, chalk) {
|
|
|
574
584
|
|
|
575
585
|
if (endpointReady && !customImage) {
|
|
576
586
|
const keySnip = config.apiKey?.slice(0, 4) || 'sk-...';
|
|
577
|
-
const sdkModel = isLlamaCpp ? 'default' : (dep.model ||
|
|
587
|
+
const sdkModel = isLlamaCpp ? 'default' : (dep.model || effectiveModel);
|
|
578
588
|
console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
|
|
579
589
|
console.log(chalk.dim(` from openai import OpenAI`));
|
|
580
590
|
console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${keySnip}...")`));
|
package/src/commands/train.js
CHANGED
|
@@ -87,11 +87,140 @@ export function findLocalDatasetPaths(configContent) {
|
|
|
87
87
|
return paths;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// badgr train lora — productized LoRA training via POST /v1/jobs
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
export function parseTrainLoraArgs(args) {
|
|
95
|
+
const flags = {};
|
|
96
|
+
let i = 0;
|
|
97
|
+
while (i < args.length) {
|
|
98
|
+
const a = args[i];
|
|
99
|
+
if (a === '--base-model') { flags.baseModel = args[++i]; i++; continue; }
|
|
100
|
+
if (a === '--dataset') { flags.dataset = args[++i]; i++; continue; }
|
|
101
|
+
if (a === '--file-id') { flags.fileId = args[++i]; i++; continue; }
|
|
102
|
+
if (a === '--preset') { flags.preset = args[++i]; i++; continue; }
|
|
103
|
+
if (a === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
|
|
104
|
+
if (a === '--max-runtime') { flags.maxRuntime = parseFloat(args[++i]); i++; continue; }
|
|
105
|
+
if (a === '--tier') { flags.tier = args[++i]; i++; continue; }
|
|
106
|
+
if (a === '--gpu-type') { flags.gpuType = args[++i]; i++; continue; }
|
|
107
|
+
i++;
|
|
108
|
+
}
|
|
109
|
+
return flags;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function trainLoraCommand(config, args, chalk) {
|
|
113
|
+
const { callApi } = await import('../api.js');
|
|
114
|
+
const { addReceipt, generateReceiptId } = await import('../store.js');
|
|
115
|
+
const flags = parseTrainLoraArgs(args);
|
|
116
|
+
|
|
117
|
+
if (!flags.baseModel) {
|
|
118
|
+
console.error(chalk.red('\n Usage: badgr train lora --base-model <model> --dataset <file-or-url> --preset small --max-cost 20\n'));
|
|
119
|
+
console.error(chalk.dim(' Dataset sources:'));
|
|
120
|
+
console.error(chalk.dim(' --dataset ./train.jsonl local file (uploaded first)'));
|
|
121
|
+
console.error(chalk.dim(' --dataset https://... direct URL'));
|
|
122
|
+
console.error(chalk.dim(' --file-id up_abc123 Badgr upload ID\n'));
|
|
123
|
+
process.exitCode = 1;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!flags.maxCost) {
|
|
128
|
+
console.error(chalk.red('\n ✗ --max-cost is required to cap GPU spend.\n'));
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
requireApiKey(config);
|
|
134
|
+
|
|
135
|
+
// Build dataset input field
|
|
136
|
+
const input = { base_model: flags.baseModel, config_preset: flags.preset || 'small' };
|
|
137
|
+
if (flags.fileId) {
|
|
138
|
+
input.dataset_file_id = flags.fileId;
|
|
139
|
+
} else if (flags.dataset && (flags.dataset.startsWith('http://') || flags.dataset.startsWith('https://') || flags.dataset.startsWith('s3://'))) {
|
|
140
|
+
input.dataset_url = flags.dataset;
|
|
141
|
+
} else if (flags.dataset) {
|
|
142
|
+
// Local file — upload first
|
|
143
|
+
const { createReadStream, statSync } = await import('fs');
|
|
144
|
+
if (!existsSync(flags.dataset)) {
|
|
145
|
+
console.error(chalk.red(`\n ✗ Dataset file not found: ${flags.dataset}\n`));
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
console.log(chalk.dim(`\n Uploading dataset ${flags.dataset}…`));
|
|
150
|
+
const FormData = (await import('formdata-node')).FormData;
|
|
151
|
+
const { fileFromPath } = await import('formdata-node/file-from-path');
|
|
152
|
+
const form = new FormData();
|
|
153
|
+
form.set('file', await fileFromPath(flags.dataset));
|
|
154
|
+
const uploadResp = await callApi(config, 'POST', '/v1/uploads', null, form);
|
|
155
|
+
input.dataset_file_id = uploadResp.upload_id;
|
|
156
|
+
console.log(chalk.dim(` Uploaded: ${uploadResp.upload_id}`));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (flags.gpuType) input.gpu_type = flags.gpuType;
|
|
160
|
+
|
|
161
|
+
const rcptId = generateReceiptId();
|
|
162
|
+
const maxRuntime = flags.maxRuntime ?? 240;
|
|
163
|
+
|
|
164
|
+
console.log(chalk.bold('\n⚡ Starting LoRA training\n'));
|
|
165
|
+
console.log(` ${chalk.bold('Base model:')} ${flags.baseModel}`);
|
|
166
|
+
console.log(` ${chalk.bold('Preset:')} ${input.config_preset}`);
|
|
167
|
+
console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost}`);
|
|
168
|
+
console.log(` ${chalk.bold('Max runtime:')} ${maxRuntime} min\n`);
|
|
169
|
+
|
|
170
|
+
let job;
|
|
171
|
+
try {
|
|
172
|
+
job = await callApi(config, 'POST', '/v1/jobs', {
|
|
173
|
+
type: 'train.lora',
|
|
174
|
+
input,
|
|
175
|
+
policy: { max_cost: flags.maxCost, max_runtime_minutes: maxRuntime, tier: flags.tier },
|
|
176
|
+
});
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.error(chalk.red(`\n ✗ Failed to submit job: ${err.message}\n`));
|
|
179
|
+
process.exitCode = 1;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
addReceipt({ id: rcptId, type: 'train.lora', job_id: job.job_id, started_at: Date.now() });
|
|
184
|
+
console.log(` ${chalk.bold('Job ID:')} ${job.job_id}`);
|
|
185
|
+
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
186
|
+
console.log(chalk.dim('\n Polling for completion — Ctrl+C to detach (GPU keeps running)\n'));
|
|
187
|
+
|
|
188
|
+
// Poll until complete
|
|
189
|
+
const startMs = Date.now();
|
|
190
|
+
const maxMs = maxRuntime * 60 * 1000;
|
|
191
|
+
while (Date.now() - startMs < maxMs) {
|
|
192
|
+
await new Promise(r => setTimeout(r, 15_000));
|
|
193
|
+
let detail;
|
|
194
|
+
try { detail = await callApi(config, 'GET', `/v1/jobs/${job.job_id}`); } catch { continue; }
|
|
195
|
+
process.stdout.write(`\r Status: ${detail.status} elapsed: ${Math.floor((Date.now() - startMs) / 1000)}s `);
|
|
196
|
+
if (detail.status === 'completed') {
|
|
197
|
+
const out = detail.output || {};
|
|
198
|
+
console.log(chalk.green('\n\n ✓ Training complete\n'));
|
|
199
|
+
if (out.adapter_url) console.log(` ${chalk.bold('Adapter:')} ${out.adapter_url}`);
|
|
200
|
+
if (out.checkpoint_url) console.log(` ${chalk.bold('Checkpoint:')} ${out.checkpoint_url}`);
|
|
201
|
+
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}\n`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (detail.status === 'failed') {
|
|
205
|
+
console.error(chalk.red(`\n\n ✗ Training failed: ${detail.error_code || ''} — ${detail.error_message || ''}\n`));
|
|
206
|
+
process.exitCode = 1;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
console.error(chalk.yellow('\n\n Training still running — detached. Check status:\n'));
|
|
211
|
+
console.error(chalk.dim(` badgr status\n`));
|
|
212
|
+
}
|
|
213
|
+
|
|
90
214
|
export async function trainCommand(config, args, chalk) {
|
|
215
|
+
// Route subcommands
|
|
216
|
+
const sub = args[0];
|
|
217
|
+
if (sub === 'lora') return trainLoraCommand(config, args.slice(1), chalk);
|
|
218
|
+
|
|
91
219
|
const { configFile, flags } = parseTrainArgs(args);
|
|
92
220
|
|
|
93
221
|
if (!configFile) {
|
|
94
222
|
console.error(chalk.red('\n Usage: badgr train config.yaml\n'));
|
|
223
|
+
console.error(chalk.dim(' Productized LoRA: badgr train lora --base-model MODEL --dataset FILE --max-cost N\n'));
|
|
95
224
|
console.error(chalk.dim(' Runs LoRA / fine-tuning on GPU and streams logs.\n'));
|
|
96
225
|
process.exitCode = 1;
|
|
97
226
|
return;
|
|
@@ -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
|
+
});
|