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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "badgr-cli",
3
- "version": "1.0.41",
3
+ "version": "1.0.42",
4
4
  "description": "Badgr — run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
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.
@@ -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;
@@ -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 ? flags.gpu.toUpperCase().replace('-', '_') : (customImage ? 'L40S' : 'AUTO');
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
- console.log(` ${chalk.bold('Model:')} ${model}`);
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(model);
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
- (model && d.model === model) ||
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
- ...(model ? { model } : {}),
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 || 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 || model) console.log(` ${chalk.bold('Model:')} ${dep.model || 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 || 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}...")`));
@@ -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
+ });