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.
- package/README.md +32 -15
- package/package.json +3 -3
- package/src/catalog.js +62 -0
- package/src/commands/comfyui.js +132 -0
- package/src/commands/run.js +201 -24
- package/src/commands/serve.js +39 -14
- package/src/commands/train.js +129 -0
- package/src/fallback.js +6 -4
- package/tests/launch-readiness.test.js +25 -0
- package/tests/productized-runners.test.js +230 -0
- package/tests/serve-lifecycle.test.js +114 -0
- package/tests/template.test.js +4 -4
- package/tests/upload.test.js +79 -0
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
|
|
|
@@ -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
|
|
108
|
-
if (elapsedSec < 180) return 'Downloading
|
|
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
|
|
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,
|
|
@@ -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
|
-
|
|
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 ||
|
|
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 ||
|
|
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
|
-
|
|
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
|
}
|
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;
|
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
|
-
//
|
|
109
|
-
//
|
|
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
|
|
112
|
+
console.log(chalk.dim('\n Provider unavailable — trying alternative provider...\n'));
|
|
112
113
|
try {
|
|
113
|
-
|
|
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
|
+
});
|