badgr-cli 1.0.40 → 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.
@@ -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
 
@@ -104,8 +104,8 @@ function _serveStageLabel(elapsedSec, healthPath = '/models') {
104
104
  return 'Waiting for /v1/models…';
105
105
  }
106
106
  if (healthPath === '/health') {
107
- if (elapsedSec < 60) return 'Starting llama-server…';
108
- if (elapsedSec < 180) return 'Downloading from Hugging Face…';
107
+ if (elapsedSec < 60) return 'Starting server…';
108
+ if (elapsedSec < 180) return 'Downloading model…';
109
109
  return 'Waiting for /health…';
110
110
  }
111
111
  if (elapsedSec < 30) return 'Starting container…';
@@ -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,
@@ -461,14 +471,19 @@ export async function serveCommand(config, args, chalk) {
461
471
  }
462
472
 
463
473
  // ── Determine health check path ───────────────────────────────────────────
464
- // Priority: explicit --health-path > llama.cpp → /health > vLLM → /models > auto-detect custom image > null
474
+ // Priority: explicit --health-path > llama.cpp → /health > task-specific > vLLM → /models > auto-detect custom image > null
465
475
  let resolvedHealthPath;
466
476
  if (flags.healthPath) {
467
477
  resolvedHealthPath = flags.healthPath;
468
478
  } else if (isLlamaCpp) {
469
479
  resolvedHealthPath = '/health';
470
480
  } else if (!customImage) {
471
- resolvedHealthPath = '/models';
481
+ // Managed runtimes for transcribe/image expose /health; vLLM (chat, embed) uses /models
482
+ if (flags.task === 'transcribe' || flags.task === 'image') {
483
+ resolvedHealthPath = '/health';
484
+ } else {
485
+ resolvedHealthPath = '/models';
486
+ }
472
487
  } else {
473
488
  resolvedHealthPath = _detectHealthPath(customImage); // '/system_stats' for comfyui, null otherwise
474
489
  }
@@ -551,7 +566,7 @@ export async function serveCommand(config, args, chalk) {
551
566
  console.log(` ${chalk.bold('HF Repo:')} ${flags.hfRepo}`);
552
567
  console.log(` ${chalk.bold('HF File:')} ${flags.hfFile}`);
553
568
  }
554
- 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}`);
555
570
  if (customImage) console.log(` ${chalk.bold('Image:')} ${customImage}`);
556
571
  console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
557
572
  if (serveRate > 0) console.log(` ${chalk.bold('Rate:')} $${serveRate.toFixed(2)}/hr`);
@@ -569,11 +584,21 @@ export async function serveCommand(config, args, chalk) {
569
584
 
570
585
  if (endpointReady && !customImage) {
571
586
  const keySnip = config.apiKey?.slice(0, 4) || 'sk-...';
572
- const sdkModel = isLlamaCpp ? 'default' : (dep.model || model);
587
+ const sdkModel = isLlamaCpp ? 'default' : (dep.model || effectiveModel);
573
588
  console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
574
589
  console.log(chalk.dim(` from openai import OpenAI`));
575
590
  console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${keySnip}...")`));
576
- console.log(chalk.dim(` resp = client.chat.completions.create(model="${sdkModel}", messages=[...])`));
591
+ if (flags.task === 'transcribe') {
592
+ console.log(chalk.dim(` with open("audio.mp3", "rb") as f:`));
593
+ console.log(chalk.dim(` t = client.audio.transcriptions.create(model="${sdkModel}", file=f, response_format="text")`));
594
+ } else if (flags.task === 'image') {
595
+ console.log(chalk.dim(` resp = client.images.generate(model="${sdkModel}", prompt="...", n=1, size="1024x1024")`));
596
+ console.log(chalk.dim(` # resp.data[0].b64_json contains the base64-encoded PNG`));
597
+ } else if (flags.task === 'embed') {
598
+ console.log(chalk.dim(` resp = client.embeddings.create(model="${sdkModel}", input=["hello world"])`));
599
+ } else {
600
+ console.log(chalk.dim(` resp = client.chat.completions.create(model="${sdkModel}", messages=[{"role": "user", "content": "Hello"}])`));
601
+ }
577
602
  console.log();
578
603
  }
579
604
  }
@@ -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;
package/src/fallback.js CHANGED
@@ -105,12 +105,14 @@ export async function callWithFallback(endpoint, callOpts, buildBody, effectiveT
105
105
 
106
106
  const d = firstErr.errorData;
107
107
 
108
- // Stale-capacity retry: PROVISIONING_FAILED means a GPU slot appeared available but
109
- // couldn't launch. Retry once immediately to pick up a fresh slot before escalating.
108
+ // Provider retry: PROVISIONING_FAILED means the selected provider couldn't launch the slot.
109
+ // Retry once with prefer_different_provider so the backend routes to a different provider
110
+ // (e.g. RunPod failed → try Vast.ai or Hyperstack) within the same max_cost budget.
110
111
  if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
111
- console.log(chalk.dim('\n Provisioning failedretrying with fresh capacity...\n'));
112
+ console.log(chalk.dim('\n Provider unavailabletrying alternative provider...\n'));
112
113
  try {
113
- return await attempt(buildBody());
114
+ const retryBody = { ...buildBody(), prefer_different_provider: true };
115
+ return await attempt(retryBody);
114
116
  } catch (retryErr) {
115
117
  if (retryErr.isPaymentRequired) throw retryErr;
116
118
  firstErr = retryErr;
@@ -355,6 +355,31 @@ describe('stale-capacity retry on PROVISIONING_FAILED', () => {
355
355
  expect(api.callApi.mock.calls.length).toBeGreaterThanOrEqual(2);
356
356
  });
357
357
 
358
+ it('sends prefer_different_provider=true on the PROVISIONING_FAILED retry', async () => {
359
+ api.callApi.mockRejectedValue(makeProvisioningFailedErr());
360
+
361
+ const bodies = [];
362
+ api.callApi.mockImplementation((_ep, opts) => {
363
+ bodies.push(opts.body);
364
+ return Promise.reject(makeProvisioningFailedErr());
365
+ });
366
+
367
+ try {
368
+ await callWithFallback(
369
+ '/run',
370
+ { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' },
371
+ () => ({ gpu: 'RTX_4090' }),
372
+ '2',
373
+ chalk,
374
+ { thing: 'job', cmd: 'badgr run' },
375
+ );
376
+ } catch (_) {}
377
+
378
+ // First call: no prefer_different_provider; retry call: must include it
379
+ expect(bodies[0]).not.toHaveProperty('prefer_different_provider');
380
+ expect(bodies[1]).toMatchObject({ prefer_different_provider: true });
381
+ });
382
+
358
383
  it('does NOT double-retry on non-PROVISIONING_FAILED errors', async () => {
359
384
  const err = new Error('NO_CAPACITY_MATCH');
360
385
  err.errorData = { code: 'NO_CAPACITY_MATCH' };
@@ -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
+ });