badgr-cli 1.0.42 → 1.0.44
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 +81 -2
- package/package.json +2 -1
- package/src/badgr.js +3 -0
- package/src/catalog.js +15 -0
- package/src/commands/comfyui.js +35 -8
- package/src/commands/run.js +87 -99
- package/src/commands/serve.js +186 -123
- package/src/commands/train.js +85 -11
- package/src/progress.js +42 -0
- package/tests/productized-dry-run.test.js +141 -0
- package/tests/serve-lifecycle.test.js +196 -137
- package/tests/template.test.js +11 -13
- package/tests/workload-templates.test.js +37 -2
package/src/commands/serve.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { requireApiKey } from '../config.js';
|
|
2
2
|
import { callApi, listDeployments } from '../api.js';
|
|
3
3
|
import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../store.js';
|
|
4
|
-
import { normalizeTier, callWithFallback
|
|
4
|
+
import { normalizeTier, callWithFallback } from '../fallback.js';
|
|
5
5
|
import { formatCliError } from '../errors.js';
|
|
6
|
-
import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides, BLESSED_VLLM_MODELS } from '../catalog.js';
|
|
6
|
+
import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides, BLESSED_VLLM_MODELS, isLikelyGatedModel } from '../catalog.js';
|
|
7
|
+
import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock } from '../progress.js';
|
|
7
8
|
|
|
8
9
|
const LLAMA_CPP_IMAGE = 'michaelmanleyx/llama-cpp:server-cuda';
|
|
9
10
|
|
|
@@ -42,6 +43,7 @@ export function parseServeArgs(args) {
|
|
|
42
43
|
if (args[i] === '--runtime') { flags.runtime = args[++i]; i++; continue; }
|
|
43
44
|
if (args[i] === '--hf-repo') { flags.hfRepo = args[++i]; i++; continue; }
|
|
44
45
|
if (args[i] === '--hf-file') { flags.hfFile = args[++i]; i++; continue; }
|
|
46
|
+
if (args[i] === '--list-aliases') { flags.listAliases = true; i++; continue; }
|
|
45
47
|
if (args[i] === '--env') {
|
|
46
48
|
const kv = args[++i]; i++;
|
|
47
49
|
if (!flags.env) flags.env = [];
|
|
@@ -63,18 +65,36 @@ function parseEnvFlag(envList) {
|
|
|
63
65
|
return obj;
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
function envObjHasHfToken(envList) {
|
|
69
|
+
return (envList || []).some(kv => kv.startsWith('HF_TOKEN='));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Extract a parameter-count-in-billions hint from a model/file name, e.g.
|
|
73
|
+
// "Qwen2.5-0.5B-Instruct" → 0.5, "Llama-3.1-8B-Instruct" → 8, "Mixtral-8x7B" → 56.
|
|
74
|
+
// Splits into delimiter-bounded segments first so a version number like "2.5"
|
|
75
|
+
// in "Qwen2.5-0.5B" is never mistaken for the param count — only a segment that
|
|
76
|
+
// IS entirely "<digits>b" or "<digits>x<digits>b" counts as a size hint.
|
|
77
|
+
function _extractParamsB(name) {
|
|
78
|
+
const s = name.toLowerCase();
|
|
79
|
+
const segments = s.split(/[^a-z0-9.]+/).filter(Boolean);
|
|
80
|
+
for (const seg of segments) {
|
|
81
|
+
const moe = seg.match(/^(\d+)x(\d+)b$/);
|
|
82
|
+
if (moe) return parseInt(moe[1], 10) * parseInt(moe[2], 10);
|
|
83
|
+
}
|
|
84
|
+
for (const seg of segments) {
|
|
85
|
+
const m = seg.match(/^(\d+(?:\.\d+)?)b$/);
|
|
86
|
+
if (m) return parseFloat(m[1]);
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
66
91
|
// Mirror of backend workload_profile.py infer_profile_from_model — for pre-flight display.
|
|
92
|
+
// Sizing is only asserted when a param-count hint is found in the name; unknown
|
|
93
|
+
// sizing falls back to the 7B–8B/24GB+ default rather than guessing small or large.
|
|
67
94
|
function _inferServeProfile(modelName) {
|
|
68
|
-
const
|
|
69
|
-
const moe = s.match(/(\d+)x(\d+)b/);
|
|
70
|
-
let paramsB;
|
|
71
|
-
if (moe) {
|
|
72
|
-
paramsB = parseInt(moe[1]) * parseInt(moe[2]);
|
|
73
|
-
} else {
|
|
74
|
-
const m = s.match(/(\d+)b/);
|
|
75
|
-
paramsB = m ? parseInt(m[1]) : null;
|
|
76
|
-
}
|
|
95
|
+
const paramsB = _extractParamsB(modelName);
|
|
77
96
|
if (paramsB === null) return { label: 'inference (7B–8B model)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] };
|
|
97
|
+
if (paramsB <= 3) return { label: 'inference (≤3B model)', vram: '8+ GB', gpus: ['RTX 3090', 'RTX 4090', 'L4'] };
|
|
78
98
|
if (paramsB <= 9) return { label: 'inference (7B–8B model)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] };
|
|
79
99
|
if (paramsB <= 35) return { label: 'inference (30B–34B model)', vram: '40+ GB', gpus: ['A6000', 'L40S', 'A100'] };
|
|
80
100
|
return { label: 'inference (70B+ model)', vram: '80+ GB', gpus: ['H100', 'A100'] };
|
|
@@ -83,35 +103,16 @@ function _inferServeProfile(modelName) {
|
|
|
83
103
|
// Mirror of backend workload_profile.py infer_profile_from_gguf.
|
|
84
104
|
// Accepts the --hf-file filename; looks for param-count hints like "35B" or "8x7B".
|
|
85
105
|
function _inferGgufProfile(ggufPath) {
|
|
86
|
-
const
|
|
87
|
-
const moe = s.match(/(\d+)x(\d+)b/);
|
|
88
|
-
let paramsB;
|
|
89
|
-
if (moe) {
|
|
90
|
-
paramsB = parseInt(moe[1]) * parseInt(moe[2]);
|
|
91
|
-
} else {
|
|
92
|
-
const m = s.match(/(\d+)b/);
|
|
93
|
-
paramsB = m ? parseInt(m[1]) : null;
|
|
94
|
-
}
|
|
106
|
+
const paramsB = _extractParamsB(ggufPath);
|
|
95
107
|
if (paramsB === null || paramsB <= 9) return { label: 'GGUF inference (≤9B, llama.cpp)', vram: '8+ GB', gpus: ['RTX 4090', 'RTX 3090', 'A6000'] };
|
|
96
108
|
if (paramsB <= 35) return { label: 'GGUF inference (10B–35B, llama.cpp)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] };
|
|
97
109
|
return { label: 'GGUF inference (36B+, llama.cpp)', vram: '48+ GB', gpus: ['A6000', 'L40S', 'A100'] };
|
|
98
110
|
}
|
|
99
111
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return 'Waiting for /v1/models…';
|
|
105
|
-
}
|
|
106
|
-
if (healthPath === '/health') {
|
|
107
|
-
if (elapsedSec < 60) return 'Starting server…';
|
|
108
|
-
if (elapsedSec < 180) return 'Downloading model…';
|
|
109
|
-
return 'Waiting for /health…';
|
|
110
|
-
}
|
|
111
|
-
if (elapsedSec < 30) return 'Starting container…';
|
|
112
|
-
if (elapsedSec < 120) return 'Container starting…';
|
|
113
|
-
return `Waiting for ${healthPath}…`;
|
|
114
|
-
}
|
|
112
|
+
// If the readiness reason hasn't changed for this long, the status word
|
|
113
|
+
// flips from "starting" to "stuck" so a silent stall never looks identical
|
|
114
|
+
// to normal progress.
|
|
115
|
+
const SERVE_STUCK_THRESHOLD_MS = 90_000;
|
|
115
116
|
|
|
116
117
|
function _detectHealthPath(image) {
|
|
117
118
|
if (!image) return null;
|
|
@@ -121,16 +122,26 @@ function _detectHealthPath(image) {
|
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
125
|
+
* Wait for the deployment's app-level endpoint to become ready by polling Badgr's
|
|
126
|
+
* own deployment status (GET /deployments/{id}) — never the RunPod proxy/pod
|
|
127
|
+
* directly. The backend does the real health_path probing (see
|
|
128
|
+
* DeploymentService.check_endpoint_readiness); a pod reporting RUNNING only
|
|
129
|
+
* means infrastructure is up, not that the app inside is serving.
|
|
126
130
|
* Returns { ready: boolean, timedOut: boolean, depFailed: boolean, failReason?: string }
|
|
127
131
|
*/
|
|
128
|
-
|
|
132
|
+
// vLLM cold start (model download + load) often exceeds 5 min on first boot.
|
|
133
|
+
const VLLM_SERVE_WAIT_MS = 15 * 60 * 1000;
|
|
134
|
+
|
|
135
|
+
async function waitForEndpoint(deploymentId, config, timeoutMs = VLLM_SERVE_WAIT_MS, chalk, healthPath = '/models', costCtx = {}) {
|
|
136
|
+
const { costPerHour = 0, maxCost = null, stageLine = '' } = costCtx;
|
|
129
137
|
const startMs = Date.now();
|
|
130
138
|
const deadline = startMs + timeoutMs;
|
|
131
139
|
|
|
140
|
+
let lastReason = null;
|
|
141
|
+
let reasonSinceMs = startMs;
|
|
142
|
+
let blockLines = 0;
|
|
143
|
+
|
|
132
144
|
while (Date.now() < deadline) {
|
|
133
|
-
// Check deployment status first — fail fast on OOM/crash before waiting more
|
|
134
145
|
try {
|
|
135
146
|
const dep = await callApi(`/deployments/${deploymentId}`, {
|
|
136
147
|
apiKey: config.apiKey,
|
|
@@ -138,28 +149,30 @@ async function waitForEndpoint(endpointUrl, deploymentId, config, timeoutMs = 5
|
|
|
138
149
|
timeoutMs: 10_000,
|
|
139
150
|
});
|
|
140
151
|
if (['failed', 'terminated', 'error', 'stopped'].includes(dep.status)) {
|
|
141
|
-
|
|
142
|
-
return { ready: false, timedOut: false, depFailed: true, failReason: dep.error || dep.status };
|
|
152
|
+
if (blockLines > 0) { _clearBlock(blockLines); blockLines = 0; }
|
|
153
|
+
return { ready: false, timedOut: false, depFailed: true, failReason: dep.fix_hint || dep.error || dep.status };
|
|
154
|
+
}
|
|
155
|
+
if (dep.endpoint_ready) {
|
|
156
|
+
if (blockLines > 0) { _clearBlock(blockLines); blockLines = 0; }
|
|
157
|
+
return { ready: true, timedOut: false, depFailed: false };
|
|
143
158
|
}
|
|
144
|
-
} catch {
|
|
145
|
-
// status check failed — continue to endpoint check
|
|
146
|
-
}
|
|
147
159
|
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
const elapsed = Math.round((now - startMs) / 1000);
|
|
162
|
+
const reason = dep.readiness_reason || 'starting';
|
|
163
|
+
if (reason !== lastReason) { lastReason = reason; reasonSinceMs = now; }
|
|
164
|
+
const statusWord = (now - reasonSinceMs) >= SERVE_STUCK_THRESHOLD_MS ? 'stuck' : 'still starting';
|
|
165
|
+
const spend = costPerHour * (elapsed / 3600);
|
|
166
|
+
|
|
167
|
+
blockLines = _writeBlock(blockLines, _renderLiveBlock(chalk, {
|
|
168
|
+
stageLine, elapsedSec: elapsed, statusWord, spend, id: deploymentId,
|
|
169
|
+
}));
|
|
151
170
|
} catch {
|
|
152
|
-
//
|
|
171
|
+
// status check failed (transient network/API issue) — retry next tick
|
|
153
172
|
}
|
|
154
|
-
|
|
155
|
-
const elapsed = Math.round((Date.now() - startMs) / 1000);
|
|
156
|
-
process.stdout.write(
|
|
157
|
-
`\r ${chalk.dim(_serveStageLabel(elapsed, healthPath) + ` (${elapsed}s)`)} `
|
|
158
|
-
);
|
|
159
173
|
await new Promise(r => setTimeout(r, 8000));
|
|
160
174
|
}
|
|
161
175
|
|
|
162
|
-
process.stdout.write('\n');
|
|
163
176
|
return { ready: false, timedOut: true, depFailed: false };
|
|
164
177
|
}
|
|
165
178
|
|
|
@@ -217,6 +230,18 @@ export async function serveCommand(config, args, chalk) {
|
|
|
217
230
|
return serveCommand(config, expandedArgs, chalk);
|
|
218
231
|
}
|
|
219
232
|
|
|
233
|
+
if (args.includes('--list-aliases')) {
|
|
234
|
+
console.log(chalk.bold('\nTested model routes:\n'));
|
|
235
|
+
for (const alias of Object.keys(BLESSED_VLLM_MODELS)) {
|
|
236
|
+
console.log(` - ${chalk.cyan(alias)}`);
|
|
237
|
+
}
|
|
238
|
+
console.log();
|
|
239
|
+
console.log(chalk.dim('You can also try a Hugging Face model ID:'));
|
|
240
|
+
console.log(chalk.dim(' badgr serve Qwen/Qwen2.5-7B-Instruct --max-cost 10'));
|
|
241
|
+
console.log();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
220
245
|
const { model, flags } = parseServeArgs(args);
|
|
221
246
|
const customImage = flags.image || null;
|
|
222
247
|
const isLlamaCpp = flags.runtime === 'llama.cpp';
|
|
@@ -304,50 +329,60 @@ export async function serveCommand(config, args, chalk) {
|
|
|
304
329
|
|
|
305
330
|
const effectiveTier = normalizeTier(flags.tier);
|
|
306
331
|
|
|
332
|
+
// Header fields + trailing note (shown after Max cost) per serve mode.
|
|
333
|
+
// Built as [label, value] pairs so every mode renders through one aligned
|
|
334
|
+
// printer instead of four hand-spaced copies.
|
|
335
|
+
let title;
|
|
336
|
+
const headerLines = [];
|
|
337
|
+
let trailingNote = null;
|
|
338
|
+
let sizeProfile = null; // { label, vram } — only shown when GPU sizing was inferred, not chosen
|
|
339
|
+
|
|
307
340
|
if (isLlamaCpp) {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
if (
|
|
314
|
-
if (gpu === 'AUTO') {
|
|
315
|
-
const prof = _inferGgufProfile(flags.hfFile);
|
|
316
|
-
console.log();
|
|
317
|
-
console.log(` ${chalk.bold('Estimated workload:')} ${prof.label}`);
|
|
318
|
-
console.log(` ${chalk.bold('Estimated minimum VRAM:')} ${prof.vram}`);
|
|
319
|
-
}
|
|
341
|
+
title = flags.hfRepo;
|
|
342
|
+
headerLines.push(['Route', 'best-effort Hugging Face model']);
|
|
343
|
+
headerLines.push(['Mode', 'OpenAI-compatible endpoint (llama.cpp)']);
|
|
344
|
+
headerLines.push(['File', flags.hfFile]);
|
|
345
|
+
trailingNote = 'Badgr will try a compatible route.';
|
|
346
|
+
if (gpu === 'AUTO') sizeProfile = _inferGgufProfile(flags.hfFile);
|
|
320
347
|
} else if (customImage) {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
348
|
+
title = 'custom container';
|
|
349
|
+
headerLines.push(['Mode', 'custom server']);
|
|
350
|
+
trailingNote = 'Badgr manages runtime, logs, caps, teardown, and receipts.\n Your container owns the app behavior.';
|
|
351
|
+
} else if (vllmAlias) {
|
|
352
|
+
title = model;
|
|
353
|
+
headerLines.push(['Route', 'tested']);
|
|
354
|
+
headerLines.push(['Mode', 'OpenAI-compatible endpoint']);
|
|
326
355
|
} else {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
333
|
-
console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
|
|
334
|
-
if (flags.task) console.log(` ${chalk.bold('Task:')} ${flags.task}`);
|
|
335
|
-
if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
|
|
336
|
-
|
|
337
|
-
if (gpu === 'AUTO') {
|
|
338
|
-
const prof = _inferServeProfile(effectiveModel);
|
|
339
|
-
console.log();
|
|
340
|
-
console.log(` ${chalk.bold('Estimated workload:')} ${prof.label}`);
|
|
341
|
-
console.log(` ${chalk.bold('Estimated minimum VRAM:')} ${prof.vram}`);
|
|
342
|
-
}
|
|
356
|
+
title = effectiveModel;
|
|
357
|
+
headerLines.push(['Route', 'best-effort Hugging Face model']);
|
|
358
|
+
headerLines.push(['Mode', 'OpenAI-compatible endpoint']);
|
|
359
|
+
trailingNote = 'Badgr will try a compatible route.';
|
|
360
|
+
if (gpu === 'AUTO') sizeProfile = _inferServeProfile(effectiveModel);
|
|
343
361
|
}
|
|
344
|
-
if (flags.
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
362
|
+
if (flags.gpu) headerLines.push(['GPU', gpuLabel]);
|
|
363
|
+
if (flags.task) headerLines.push(['Task', flags.task]);
|
|
364
|
+
if (flags.env?.length) headerLines.push(['Env', flags.env.join(', ')]);
|
|
365
|
+
|
|
366
|
+
console.log(chalk.bold(`\n⚡ Serving ${title}\n`));
|
|
367
|
+
const labelWidth = Math.max(...headerLines.map(([label]) => label.length)) + 1;
|
|
368
|
+
for (const [label, value] of headerLines) {
|
|
369
|
+
console.log(` ${chalk.bold(`${label}:`.padEnd(labelWidth + 1))}${value}`);
|
|
370
|
+
}
|
|
371
|
+
if (sizeProfile) {
|
|
372
|
+
console.log();
|
|
373
|
+
console.log(` ${chalk.bold('Estimated workload:')} ${sizeProfile.label}`);
|
|
374
|
+
console.log(` ${chalk.bold('Estimated minimum VRAM:')} ${sizeProfile.vram}`);
|
|
375
|
+
}
|
|
376
|
+
console.log(` ${chalk.bold('Max cost:')} ${flags.maxCost ? `$${flags.maxCost.toFixed(2)}` : chalk.dim('none')}`);
|
|
377
|
+
console.log(` ${chalk.bold('Auto-stop:')} ${flags.maxCost ? 'enabled' : chalk.yellow('disabled — stop manually with badgr down')}`);
|
|
378
|
+
if (trailingNote) {
|
|
379
|
+
console.log();
|
|
380
|
+
console.log(chalk.dim(` ${trailingNote}`));
|
|
348
381
|
}
|
|
349
382
|
console.log();
|
|
350
|
-
|
|
383
|
+
|
|
384
|
+
const STAGE_TOTAL = 5;
|
|
385
|
+
let stageN = 1;
|
|
351
386
|
|
|
352
387
|
// ── Duplicate check ────────────────────────────────────────────────────────
|
|
353
388
|
if (config.apiKey) {
|
|
@@ -402,6 +437,7 @@ export async function serveCommand(config, args, chalk) {
|
|
|
402
437
|
tier: tierOverride || effectiveTier,
|
|
403
438
|
...(Object.keys(effectiveEnv).length > 0 ? { env: effectiveEnv } : {}),
|
|
404
439
|
...(flags.maxCost ? { max_cost_usd: flags.maxCost } : {}),
|
|
440
|
+
...(flags.healthPath ? { health_path: flags.healthPath } : {}),
|
|
405
441
|
};
|
|
406
442
|
}
|
|
407
443
|
|
|
@@ -458,6 +494,11 @@ export async function serveCommand(config, args, chalk) {
|
|
|
458
494
|
createdAt: new Date().toISOString(),
|
|
459
495
|
});
|
|
460
496
|
|
|
497
|
+
console.log(chalk.dim(_stageDone(stageN, STAGE_TOTAL, 'Finding a working route...')));
|
|
498
|
+
stageN++;
|
|
499
|
+
console.log(chalk.dim(_stageDone(stageN, STAGE_TOTAL, 'Starting runtime...')));
|
|
500
|
+
stageN++;
|
|
501
|
+
|
|
461
502
|
// ── Fix 7: never fall back to config.baseUrl for endpoint health check ─────
|
|
462
503
|
const endpointUrl = dep.endpoint_url || dep.openai_base_url;
|
|
463
504
|
if (!endpointUrl) {
|
|
@@ -488,18 +529,47 @@ export async function serveCommand(config, args, chalk) {
|
|
|
488
529
|
resolvedHealthPath = _detectHealthPath(customImage); // '/system_stats' for comfyui, null otherwise
|
|
489
530
|
}
|
|
490
531
|
|
|
532
|
+
// Gated-model guidance is only shown when it's actually needed — on failure —
|
|
533
|
+
// not up front, so common launches stay short and uncluttered.
|
|
534
|
+
const gatedModelId = isLlamaCpp ? flags.hfRepo : effectiveModel;
|
|
535
|
+
const gatedHintNeeded = isLikelyGatedModel(gatedModelId) && !envObjHasHfToken(flags.env);
|
|
536
|
+
|
|
537
|
+
// Shared reporting for "deployment failed to start" — hit from both the
|
|
538
|
+
// pre-poll status check and the waitForEndpoint poll loop below.
|
|
539
|
+
function reportDeployFailure(failReason) {
|
|
540
|
+
console.error(formatCliError('HEALTH_CHECK_DEPLOY_FAILED', {
|
|
541
|
+
deploymentId: dep.deployment_id,
|
|
542
|
+
failReason,
|
|
543
|
+
}, chalk));
|
|
544
|
+
if (gatedHintNeeded) {
|
|
545
|
+
const rerun = args.join(' ');
|
|
546
|
+
const retryCmd = rerun.includes('HF_TOKEN=') ? rerun : `${rerun} --env HF_TOKEN=$HF_TOKEN`;
|
|
547
|
+
console.error();
|
|
548
|
+
console.error(chalk.yellow(' This model may require Hugging Face access.'));
|
|
549
|
+
console.error(chalk.dim(' Retry:'));
|
|
550
|
+
console.error(chalk.dim(` badgr serve ${retryCmd}`));
|
|
551
|
+
}
|
|
552
|
+
updateReceipt(rcptId, { status: 'failed', failReason });
|
|
553
|
+
process.exitCode = 1;
|
|
554
|
+
}
|
|
555
|
+
|
|
491
556
|
// ── Health check ──────────────────────────────────────────────────────────
|
|
557
|
+
const loadingStageN = stageN; // "Loading model..."
|
|
558
|
+
const healthStageN = stageN + 1; // "Checking endpoint health..."
|
|
559
|
+
const readyStageN = stageN + 2; // "Ready"
|
|
560
|
+
|
|
492
561
|
let endpointReady = false;
|
|
493
562
|
if (flags.noWait) {
|
|
494
|
-
console.log(chalk.yellow('
|
|
563
|
+
console.log(chalk.dim(_stage(loadingStageN, STAGE_TOTAL, 'Loading model...')) + chalk.yellow(' (skipped — --no-wait)'));
|
|
564
|
+
console.log(chalk.dim(_stage(healthStageN, STAGE_TOTAL, 'Checking endpoint health...')) + chalk.yellow(' (skipped — --no-wait)'));
|
|
565
|
+
console.log(chalk.yellow(_stage(readyStageN, STAGE_TOTAL, 'Not confirmed ready — check badgr logs') + `\n`));
|
|
495
566
|
} else if (resolvedHealthPath === null) {
|
|
567
|
+
console.log(chalk.dim(_stage(loadingStageN, STAGE_TOTAL, 'Loading model...')));
|
|
496
568
|
console.log(chalk.yellow(
|
|
497
|
-
|
|
569
|
+
_stage(healthStageN, STAGE_TOTAL, 'Checking endpoint health...') +
|
|
570
|
+
' (skipped — custom image; add --health-path /your-readiness-path to enable)\n'
|
|
498
571
|
));
|
|
499
572
|
} else {
|
|
500
|
-
if (resolvedHealthPath !== '/models') {
|
|
501
|
-
process.stdout.write(chalk.dim(` Checking ${resolvedHealthPath} for readiness…\n`));
|
|
502
|
-
}
|
|
503
573
|
// Check deployment status once before starting the 5-min wait
|
|
504
574
|
try {
|
|
505
575
|
const latest = await callApi(`/deployments/${dep.deployment_id}`, {
|
|
@@ -508,33 +578,31 @@ export async function serveCommand(config, args, chalk) {
|
|
|
508
578
|
timeoutMs: 10_000,
|
|
509
579
|
});
|
|
510
580
|
if (['failed', 'terminated', 'error'].includes(latest.status)) {
|
|
511
|
-
|
|
512
|
-
deploymentId: dep.deployment_id,
|
|
513
|
-
failReason: latest.error || latest.status,
|
|
514
|
-
}, chalk));
|
|
515
|
-
updateReceipt(rcptId, { status: 'failed', failReason: latest.error || latest.status });
|
|
516
|
-
process.exitCode = 1;
|
|
581
|
+
reportDeployFailure(latest.error || latest.status);
|
|
517
582
|
return;
|
|
518
583
|
}
|
|
519
584
|
} catch {
|
|
520
585
|
// status check failed — proceed with endpoint poll anyway
|
|
521
586
|
}
|
|
522
587
|
|
|
523
|
-
const
|
|
524
|
-
|
|
588
|
+
const loadingStageLine = _stage(loadingStageN, STAGE_TOTAL, 'Loading model...');
|
|
589
|
+
const healthResult = await waitForEndpoint(
|
|
590
|
+
dep.deployment_id, config, VLLM_SERVE_WAIT_MS, chalk, resolvedHealthPath,
|
|
591
|
+
{ costPerHour: dep.cost_per_hour || 0, maxCost: flags.maxCost || null, stageLine: loadingStageLine },
|
|
592
|
+
);
|
|
525
593
|
|
|
526
594
|
if (healthResult.depFailed) {
|
|
527
|
-
|
|
528
|
-
deploymentId: dep.deployment_id,
|
|
529
|
-
failReason: healthResult.failReason,
|
|
530
|
-
}, chalk));
|
|
531
|
-
updateReceipt(rcptId, { status: 'failed', failReason: healthResult.failReason });
|
|
532
|
-
process.exitCode = 1;
|
|
595
|
+
reportDeployFailure(healthResult.failReason);
|
|
533
596
|
return;
|
|
534
597
|
}
|
|
535
598
|
|
|
536
599
|
endpointReady = healthResult.ready;
|
|
537
|
-
if (
|
|
600
|
+
if (endpointReady) {
|
|
601
|
+
console.log(chalk.dim(_stage(healthStageN, STAGE_TOTAL, 'Checking endpoint health...')));
|
|
602
|
+
console.log(chalk.green(_stage(readyStageN, STAGE_TOTAL, 'Ready')));
|
|
603
|
+
} else {
|
|
604
|
+
updateReceipt(rcptId, { status: 'health_check_timeout' });
|
|
605
|
+
}
|
|
538
606
|
}
|
|
539
607
|
|
|
540
608
|
// ── Custom-node validation (ComfyUI) ─────────────────────────────────────
|
|
@@ -559,8 +627,6 @@ export async function serveCommand(config, args, chalk) {
|
|
|
559
627
|
console.log();
|
|
560
628
|
}
|
|
561
629
|
|
|
562
|
-
const serveRate = dep.cost_per_hour || 0;
|
|
563
|
-
|
|
564
630
|
console.log(` ${chalk.bold('Base URL:')} ${chalk.cyan(endpointUrl)}`);
|
|
565
631
|
if (isLlamaCpp) {
|
|
566
632
|
console.log(` ${chalk.bold('HF Repo:')} ${flags.hfRepo}`);
|
|
@@ -568,23 +634,20 @@ export async function serveCommand(config, args, chalk) {
|
|
|
568
634
|
}
|
|
569
635
|
else if (dep.model || effectiveModel) console.log(` ${chalk.bold('Model:')} ${dep.model || effectiveModel}`);
|
|
570
636
|
if (customImage) console.log(` ${chalk.bold('Image:')} ${customImage}`);
|
|
571
|
-
console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
|
|
572
|
-
if (serveRate > 0) console.log(` ${chalk.bold('Rate:')} $${serveRate.toFixed(2)}/hr`);
|
|
573
637
|
if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
|
|
574
|
-
console.log(` ${chalk.bold('
|
|
575
|
-
console.log(` ${chalk.bold('Logs:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
|
|
638
|
+
console.log(` ${chalk.bold('Logs:')} badgr logs ${dep.deployment_id}`);
|
|
576
639
|
console.log(` ${chalk.bold('Stop billing:')} ${chalk.cyan(`badgr down ${dep.deployment_id}`)}`);
|
|
640
|
+
console.log(` ${chalk.bold('Receipt:')} badgr receipts ${rcptId}`);
|
|
577
641
|
console.log();
|
|
578
|
-
|
|
579
|
-
if (serveRate > HIGH_RATE_THRESHOLD && !flags.maxCost) {
|
|
580
|
-
console.log(chalk.yellow(` Selected capacity rate: $${serveRate.toFixed(2)}/hr`));
|
|
581
|
-
console.log(chalk.dim(' Tip: use --max-cost to enforce a hard ceiling.\n'));
|
|
582
|
-
}
|
|
583
642
|
console.log(chalk.dim(' Billing continues until you run: ') + chalk.cyan(`badgr down ${dep.deployment_id}`));
|
|
584
643
|
|
|
585
644
|
if (endpointReady && !customImage) {
|
|
586
645
|
const keySnip = config.apiKey?.slice(0, 4) || 'sk-...';
|
|
587
646
|
const sdkModel = isLlamaCpp ? 'default' : (dep.model || effectiveModel);
|
|
647
|
+
console.log(` ${chalk.bold('Test with curl:')}`);
|
|
648
|
+
console.log(chalk.dim(` curl ${endpointUrl}/chat/completions \\`));
|
|
649
|
+
console.log(chalk.dim(` -H "Authorization: Bearer ${keySnip}..." -H "Content-Type: application/json" \\`));
|
|
650
|
+
console.log(chalk.dim(` -d '{"model":"${sdkModel}","messages":[{"role":"user","content":"Hello"}]}'`));
|
|
588
651
|
console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
|
|
589
652
|
console.log(chalk.dim(` from openai import OpenAI`));
|
|
590
653
|
console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${keySnip}...")`));
|
package/src/commands/train.js
CHANGED
|
@@ -21,6 +21,17 @@ const TRAINING_IMAGES = {
|
|
|
21
21
|
generic: 'nvidia/cuda:12.1.0-cudnn8-devel-ubuntu22.04',
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
// Container command per framework — must match what's actually installed in
|
|
25
|
+
// TRAINING_IMAGES[framework]. A framework with no entry here has no known
|
|
26
|
+
// working command and is blocked in trainCommand() rather than guessed at,
|
|
27
|
+
// since a wrong guess still provisions (and bills) the GPU before failing.
|
|
28
|
+
const TRAINING_COMMANDS = {
|
|
29
|
+
axolotl: 'echo "$TRAIN_CONFIG_B64" | base64 -d > /tmp/config.yaml && axolotl train /tmp/config.yaml',
|
|
30
|
+
// huggingface/trl-source ships the `trl` CLI (trl sft|dpo|kto --config <yaml>).
|
|
31
|
+
// We default to `sft` — the common case for a plain base_model+dataset config.
|
|
32
|
+
trl: 'echo "$TRAIN_CONFIG_B64" | base64 -d > /tmp/config.yaml && trl sft --config /tmp/config.yaml',
|
|
33
|
+
};
|
|
34
|
+
|
|
24
35
|
// Preferred GPUs for training: VRAM-heavy workloads.
|
|
25
36
|
const TRAINING_GPUS = ['A100', 'H100', 'L40S', 'A6000'];
|
|
26
37
|
|
|
@@ -104,11 +115,18 @@ export function parseTrainLoraArgs(args) {
|
|
|
104
115
|
if (a === '--max-runtime') { flags.maxRuntime = parseFloat(args[++i]); i++; continue; }
|
|
105
116
|
if (a === '--tier') { flags.tier = args[++i]; i++; continue; }
|
|
106
117
|
if (a === '--gpu-type') { flags.gpuType = args[++i]; i++; continue; }
|
|
118
|
+
if (a === '--dry-run') { flags.dryRun = true; i++; continue; }
|
|
107
119
|
i++;
|
|
108
120
|
}
|
|
109
121
|
return flags;
|
|
110
122
|
}
|
|
111
123
|
|
|
124
|
+
// Mirror of backend LORA_PRESETS (jobs_routes.py) — display only, server is authoritative.
|
|
125
|
+
export const LORA_PRESET_INFO = {
|
|
126
|
+
small: { gpu_type: 'RTX_4090', rank: 16, epochs: 3, description: 'Fast, low-cost — good default for most datasets' },
|
|
127
|
+
medium: { gpu_type: 'A100', rank: 32, epochs: 5, description: 'Larger rank/more epochs — bigger datasets or higher quality' },
|
|
128
|
+
};
|
|
129
|
+
|
|
112
130
|
export async function trainLoraCommand(config, args, chalk) {
|
|
113
131
|
const { callApi } = await import('../api.js');
|
|
114
132
|
const { addReceipt, generateReceiptId } = await import('../store.js');
|
|
@@ -124,12 +142,31 @@ export async function trainLoraCommand(config, args, chalk) {
|
|
|
124
142
|
return;
|
|
125
143
|
}
|
|
126
144
|
|
|
127
|
-
if (!flags.maxCost) {
|
|
145
|
+
if (!flags.maxCost && !flags.dryRun) {
|
|
128
146
|
console.error(chalk.red('\n ✗ --max-cost is required to cap GPU spend.\n'));
|
|
129
147
|
process.exitCode = 1;
|
|
130
148
|
return;
|
|
131
149
|
}
|
|
132
150
|
|
|
151
|
+
if (flags.dryRun) {
|
|
152
|
+
const preset = flags.preset || 'small';
|
|
153
|
+
const presetInfo = LORA_PRESET_INFO[preset];
|
|
154
|
+
console.log(chalk.bold('\n⚡ Dry run — no GPU will be provisioned\n'));
|
|
155
|
+
console.log(` ${chalk.bold('Base model:')} ${flags.baseModel}`);
|
|
156
|
+
console.log(` ${chalk.bold('Dataset:')} ${flags.fileId || flags.dataset || chalk.dim('(none given)')}`);
|
|
157
|
+
console.log(` ${chalk.bold('Preset:')} ${preset}${presetInfo ? '' : chalk.yellow(' (unknown — server will reject this)')}`);
|
|
158
|
+
if (presetInfo) {
|
|
159
|
+
console.log(` ${chalk.bold('GPU:')} ${flags.gpuType || presetInfo.gpu_type}`);
|
|
160
|
+
console.log(` ${chalk.bold('LoRA rank:')} ${presetInfo.rank}`);
|
|
161
|
+
console.log(` ${chalk.bold('Epochs:')} ${presetInfo.epochs}`);
|
|
162
|
+
console.log(` ${chalk.dim(presetInfo.description)}`);
|
|
163
|
+
}
|
|
164
|
+
if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost}`);
|
|
165
|
+
console.log(` ${chalk.bold('Max runtime:')} ${flags.maxRuntime ?? 240}min`);
|
|
166
|
+
console.log(chalk.dim('\n Remove --dry-run to submit (local file datasets are uploaded first).\n'));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
133
170
|
requireApiKey(config);
|
|
134
171
|
|
|
135
172
|
// Build dataset input field
|
|
@@ -147,11 +184,23 @@ export async function trainLoraCommand(config, args, chalk) {
|
|
|
147
184
|
return;
|
|
148
185
|
}
|
|
149
186
|
console.log(chalk.dim(`\n Uploading dataset ${flags.dataset}…`));
|
|
150
|
-
const
|
|
151
|
-
const
|
|
187
|
+
const { readFileSync: readDs } = await import('fs');
|
|
188
|
+
const fileData = readDs(flags.dataset);
|
|
152
189
|
const form = new FormData();
|
|
153
|
-
form.
|
|
154
|
-
const
|
|
190
|
+
form.append('file', new Blob([fileData]), flags.dataset.split('/').pop() || 'dataset.jsonl');
|
|
191
|
+
const baseUrl = config.baseUrl.replace(/\/v1\/?$/, '');
|
|
192
|
+
const uploadRes = await fetch(`${baseUrl}/v1/uploads`, {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
body: form,
|
|
195
|
+
headers: { Authorization: `Bearer ${config.apiKey}` },
|
|
196
|
+
});
|
|
197
|
+
if (!uploadRes.ok) {
|
|
198
|
+
const text = await uploadRes.text().catch(() => '');
|
|
199
|
+
console.error(chalk.red(`\n ✗ Dataset upload failed: ${uploadRes.status}${text ? ` — ${text}` : ''}\n`));
|
|
200
|
+
process.exitCode = 1;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const uploadResp = await uploadRes.json();
|
|
155
204
|
input.dataset_file_id = uploadResp.upload_id;
|
|
156
205
|
console.log(chalk.dim(` Uploaded: ${uploadResp.upload_id}`));
|
|
157
206
|
}
|
|
@@ -169,10 +218,15 @@ export async function trainLoraCommand(config, args, chalk) {
|
|
|
169
218
|
|
|
170
219
|
let job;
|
|
171
220
|
try {
|
|
172
|
-
job = await callApi(
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
221
|
+
job = await callApi('/jobs', {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
apiKey: config.apiKey,
|
|
224
|
+
baseUrl: config.baseUrl,
|
|
225
|
+
body: {
|
|
226
|
+
type: 'train.lora',
|
|
227
|
+
input,
|
|
228
|
+
policy: { max_cost: flags.maxCost, max_runtime_minutes: maxRuntime, tier: flags.tier },
|
|
229
|
+
},
|
|
176
230
|
});
|
|
177
231
|
} catch (err) {
|
|
178
232
|
console.error(chalk.red(`\n ✗ Failed to submit job: ${err.message}\n`));
|
|
@@ -191,7 +245,12 @@ export async function trainLoraCommand(config, args, chalk) {
|
|
|
191
245
|
while (Date.now() - startMs < maxMs) {
|
|
192
246
|
await new Promise(r => setTimeout(r, 15_000));
|
|
193
247
|
let detail;
|
|
194
|
-
try {
|
|
248
|
+
try {
|
|
249
|
+
detail = await callApi(`/jobs/${job.job_id}`, {
|
|
250
|
+
apiKey: config.apiKey,
|
|
251
|
+
baseUrl: config.baseUrl,
|
|
252
|
+
});
|
|
253
|
+
} catch { continue; }
|
|
195
254
|
process.stdout.write(`\r Status: ${detail.status} elapsed: ${Math.floor((Date.now() - startMs) / 1000)}s `);
|
|
196
255
|
if (detail.status === 'completed') {
|
|
197
256
|
const out = detail.output || {};
|
|
@@ -287,12 +346,27 @@ export async function trainCommand(config, args, chalk) {
|
|
|
287
346
|
console.log(` ${chalk.bold('Max runtime:')} ${runtimeLabel}`);
|
|
288
347
|
if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
|
|
289
348
|
console.log();
|
|
349
|
+
|
|
350
|
+
// Block before provisioning: TRAINING_IMAGES[framework] and the container
|
|
351
|
+
// command must be a matched pair, or the job burns GPU time and then fails
|
|
352
|
+
// (e.g. an axolotl `train` binary that doesn't exist in the unsloth image).
|
|
353
|
+
const trainCmd = TRAINING_COMMANDS[framework];
|
|
354
|
+
if (!trainCmd) {
|
|
355
|
+
console.error(chalk.red(` ✗ No runnable command for framework '${framework}' — refusing to provision a GPU that will fail.\n`));
|
|
356
|
+
console.error(chalk.dim(` '${image}' does not have a known working entrypoint for this config yet.`));
|
|
357
|
+
console.error(chalk.dim(` Options:`));
|
|
358
|
+
console.error(chalk.dim(` --framework axolotl force Axolotl (it has native Unsloth-optimization support via config keys)`));
|
|
359
|
+
console.error(chalk.dim(` badgr train lora ... use the productized LoRA path instead\n`));
|
|
360
|
+
process.exitCode = 1;
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
290
364
|
process.stdout.write(chalk.dim(' Finding suitable capacity...\n'));
|
|
291
365
|
|
|
292
366
|
function buildBody(tierOverride) {
|
|
293
367
|
return {
|
|
294
368
|
image,
|
|
295
|
-
command: ['sh', '-c',
|
|
369
|
+
command: ['sh', '-c', trainCmd],
|
|
296
370
|
gpu,
|
|
297
371
|
gpu_count: 1,
|
|
298
372
|
...(flags.region ? { region: flags.region.toUpperCase() } : {}),
|