badgr-cli 1.0.43 → 1.0.45
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 +180 -230
- package/package.json +1 -1
- package/src/badgr.js +12 -0
- package/src/catalog.js +46 -0
- package/src/commands/comfyui.js +70 -56
- package/src/commands/detect.js +58 -0
- package/src/commands/receipts.js +39 -2
- package/src/commands/run.js +150 -68
- package/src/commands/serve.js +278 -115
- package/src/commands/train.js +22 -27
- package/src/detect.js +362 -0
- package/src/progress.js +202 -0
- package/src/store.js +11 -0
- package/tests/detect.test.js +191 -0
- package/tests/job-progress-poll.test.js +136 -0
- package/tests/productized-runners.test.js +7 -0
- package/tests/run-lifecycle.test.js +111 -1
- package/tests/serve-apps.test.js +189 -0
- package/tests/serve-lifecycle.test.js +116 -2
- package/tests/store.test.js +22 -1
- package/tests/template.test.js +4 -4
- package/tests/workload-templates.test.js +22 -0
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, printFailureClass as _printFailureClass } from '../progress.js';
|
|
7
8
|
|
|
8
9
|
const LLAMA_CPP_IMAGE = 'michaelmanleyx/llama-cpp:server-cuda';
|
|
9
10
|
|
|
@@ -64,18 +65,36 @@ function parseEnvFlag(envList) {
|
|
|
64
65
|
return obj;
|
|
65
66
|
}
|
|
66
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
|
+
|
|
67
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.
|
|
68
94
|
function _inferServeProfile(modelName) {
|
|
69
|
-
const
|
|
70
|
-
const moe = s.match(/(\d+)x(\d+)b/);
|
|
71
|
-
let paramsB;
|
|
72
|
-
if (moe) {
|
|
73
|
-
paramsB = parseInt(moe[1]) * parseInt(moe[2]);
|
|
74
|
-
} else {
|
|
75
|
-
const m = s.match(/(\d+)b/);
|
|
76
|
-
paramsB = m ? parseInt(m[1]) : null;
|
|
77
|
-
}
|
|
95
|
+
const paramsB = _extractParamsB(modelName);
|
|
78
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'] };
|
|
79
98
|
if (paramsB <= 9) return { label: 'inference (7B–8B model)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] };
|
|
80
99
|
if (paramsB <= 35) return { label: 'inference (30B–34B model)', vram: '40+ GB', gpus: ['A6000', 'L40S', 'A100'] };
|
|
81
100
|
return { label: 'inference (70B+ model)', vram: '80+ GB', gpus: ['H100', 'A100'] };
|
|
@@ -84,32 +103,16 @@ function _inferServeProfile(modelName) {
|
|
|
84
103
|
// Mirror of backend workload_profile.py infer_profile_from_gguf.
|
|
85
104
|
// Accepts the --hf-file filename; looks for param-count hints like "35B" or "8x7B".
|
|
86
105
|
function _inferGgufProfile(ggufPath) {
|
|
87
|
-
const
|
|
88
|
-
const moe = s.match(/(\d+)x(\d+)b/);
|
|
89
|
-
let paramsB;
|
|
90
|
-
if (moe) {
|
|
91
|
-
paramsB = parseInt(moe[1]) * parseInt(moe[2]);
|
|
92
|
-
} else {
|
|
93
|
-
const m = s.match(/(\d+)b/);
|
|
94
|
-
paramsB = m ? parseInt(m[1]) : null;
|
|
95
|
-
}
|
|
106
|
+
const paramsB = _extractParamsB(ggufPath);
|
|
96
107
|
if (paramsB === null || paramsB <= 9) return { label: 'GGUF inference (≤9B, llama.cpp)', vram: '8+ GB', gpus: ['RTX 4090', 'RTX 3090', 'A6000'] };
|
|
97
108
|
if (paramsB <= 35) return { label: 'GGUF inference (10B–35B, llama.cpp)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] };
|
|
98
109
|
return { label: 'GGUF inference (36B+, llama.cpp)', vram: '48+ GB', gpus: ['A6000', 'L40S', 'A100'] };
|
|
99
110
|
}
|
|
100
111
|
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
case 'port_unreachable': return 'Waiting for container to start…';
|
|
106
|
-
case 'http_404': return 'Model loading — waiting for the API to come up…';
|
|
107
|
-
case 'http_error': return `Waiting for ${healthPath || 'endpoint'} (non-200 response)…`;
|
|
108
|
-
default: break;
|
|
109
|
-
}
|
|
110
|
-
if (elapsedSec < 30) return 'Starting container…';
|
|
111
|
-
return `Waiting for ${healthPath || 'endpoint'}…`;
|
|
112
|
-
}
|
|
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;
|
|
113
116
|
|
|
114
117
|
function _detectHealthPath(image) {
|
|
115
118
|
if (!image) return null;
|
|
@@ -129,10 +132,15 @@ function _detectHealthPath(image) {
|
|
|
129
132
|
// vLLM cold start (model download + load) often exceeds 5 min on first boot.
|
|
130
133
|
const VLLM_SERVE_WAIT_MS = 15 * 60 * 1000;
|
|
131
134
|
|
|
132
|
-
async function waitForEndpoint(deploymentId, config, timeoutMs = VLLM_SERVE_WAIT_MS, chalk, healthPath = '/models') {
|
|
135
|
+
async function waitForEndpoint(deploymentId, config, timeoutMs = VLLM_SERVE_WAIT_MS, chalk, healthPath = '/models', costCtx = {}) {
|
|
136
|
+
const { costPerHour = 0, maxCost = null, stageLine = '' } = costCtx;
|
|
133
137
|
const startMs = Date.now();
|
|
134
138
|
const deadline = startMs + timeoutMs;
|
|
135
139
|
|
|
140
|
+
let lastReason = null;
|
|
141
|
+
let reasonSinceMs = startMs;
|
|
142
|
+
let blockLines = 0;
|
|
143
|
+
|
|
136
144
|
while (Date.now() < deadline) {
|
|
137
145
|
try {
|
|
138
146
|
const dep = await callApi(`/deployments/${deploymentId}`, {
|
|
@@ -141,24 +149,35 @@ async function waitForEndpoint(deploymentId, config, timeoutMs = VLLM_SERVE_WAIT
|
|
|
141
149
|
timeoutMs: 10_000,
|
|
142
150
|
});
|
|
143
151
|
if (['failed', 'terminated', 'error', 'stopped'].includes(dep.status)) {
|
|
144
|
-
|
|
145
|
-
return {
|
|
152
|
+
if (blockLines > 0) { _clearBlock(blockLines); blockLines = 0; }
|
|
153
|
+
return {
|
|
154
|
+
ready: false, timedOut: false, depFailed: true,
|
|
155
|
+
failReason: dep.fix_hint || dep.error || dep.status,
|
|
156
|
+
failureClass: dep.failure_class ?? null,
|
|
157
|
+
nextAction: dep.next_action ?? null,
|
|
158
|
+
};
|
|
146
159
|
}
|
|
147
160
|
if (dep.endpoint_ready) {
|
|
148
|
-
|
|
161
|
+
if (blockLines > 0) { _clearBlock(blockLines); blockLines = 0; }
|
|
149
162
|
return { ready: true, timedOut: false, depFailed: false };
|
|
150
163
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
164
|
+
|
|
165
|
+
const now = Date.now();
|
|
166
|
+
const elapsed = Math.round((now - startMs) / 1000);
|
|
167
|
+
const reason = dep.readiness_reason || 'starting';
|
|
168
|
+
if (reason !== lastReason) { lastReason = reason; reasonSinceMs = now; }
|
|
169
|
+
const statusWord = (now - reasonSinceMs) >= SERVE_STUCK_THRESHOLD_MS ? 'stuck' : 'still starting';
|
|
170
|
+
const spend = costPerHour * (elapsed / 3600);
|
|
171
|
+
|
|
172
|
+
blockLines = _writeBlock(blockLines, _renderLiveBlock(chalk, {
|
|
173
|
+
stageLine, elapsedSec: elapsed, statusWord, spend, id: deploymentId,
|
|
174
|
+
}));
|
|
155
175
|
} catch {
|
|
156
176
|
// status check failed (transient network/API issue) — retry next tick
|
|
157
177
|
}
|
|
158
178
|
await new Promise(r => setTimeout(r, 8000));
|
|
159
179
|
}
|
|
160
180
|
|
|
161
|
-
process.stdout.write('\n');
|
|
162
181
|
return { ready: false, timedOut: true, depFailed: false };
|
|
163
182
|
}
|
|
164
183
|
|
|
@@ -194,6 +213,101 @@ const _KNOWN_SERVE_FLAGS = new Set([
|
|
|
194
213
|
'--persistent', '--yes', '-y', '--runtime', '--hf-repo', '--hf-file',
|
|
195
214
|
]);
|
|
196
215
|
|
|
216
|
+
function extractFlag(args, flagName) {
|
|
217
|
+
const rest = [];
|
|
218
|
+
let value = null;
|
|
219
|
+
for (let i = 0; i < args.length; i++) {
|
|
220
|
+
if (args[i] === flagName) { value = args[++i]; continue; }
|
|
221
|
+
rest.push(args[i]);
|
|
222
|
+
}
|
|
223
|
+
return { value, rest };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Returns { id, endpointUrl } for a running vLLM endpoint matching modelId, or
|
|
227
|
+
// null. The id is needed so a caller that auto-launches a new one can print a
|
|
228
|
+
// precise `badgr down <id>` hint if something later fails.
|
|
229
|
+
async function findRunningVllmEndpoint(config, modelId) {
|
|
230
|
+
try {
|
|
231
|
+
const existing = await listDeployments(config);
|
|
232
|
+
const ACTIVE = new Set(['running', 'provisioning', 'starting', 'queued']);
|
|
233
|
+
const match = (existing.deployments ?? []).find(d =>
|
|
234
|
+
ACTIVE.has(d.status) && d.workload_type === 'endpoint' && d.model === modelId
|
|
235
|
+
);
|
|
236
|
+
if (!match) return null;
|
|
237
|
+
const endpointUrl = match.endpoint_url || match.openai_base_url || null;
|
|
238
|
+
return endpointUrl ? { id: match.deployment_id, endpointUrl } : null;
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* `badgr serve openwebui [--model <alias-or-id>] [--connect <url>] [flags]`
|
|
246
|
+
*
|
|
247
|
+
* Open WebUI serves a chat UI that connects to a model endpoint — usually
|
|
248
|
+
* vLLM. It is a separate served app from the model itself: this reuses a
|
|
249
|
+
* running vLLM endpoint for the model if one exists, or launches one via
|
|
250
|
+
* `serveCommand` first, then launches Open WebUI wired to it via
|
|
251
|
+
* OPENAI_API_BASE_URL/OPENAI_API_KEY. `--connect` skips vLLM discovery/launch
|
|
252
|
+
* entirely and points Open WebUI at any existing OpenAI-compatible endpoint —
|
|
253
|
+
* no model is ever touched in that mode.
|
|
254
|
+
*/
|
|
255
|
+
async function serveOpenWebUICommand(config, args, chalk) {
|
|
256
|
+
requireApiKey(config);
|
|
257
|
+
const { value: connect, rest: afterConnect } = extractFlag(args, '--connect');
|
|
258
|
+
const { value: model, rest } = extractFlag(afterConnect, '--model');
|
|
259
|
+
|
|
260
|
+
let endpointUrl = connect || null;
|
|
261
|
+
let launchedVllmDepId = null; // set only if we launched a new vLLM (for cleanup hints)
|
|
262
|
+
|
|
263
|
+
if (!endpointUrl) {
|
|
264
|
+
const alias = model || 'qwen-7b';
|
|
265
|
+
const vllmAlias = BLESSED_VLLM_MODELS[alias];
|
|
266
|
+
const modelId = vllmAlias ? vllmAlias.model_id : alias;
|
|
267
|
+
|
|
268
|
+
const existing = await findRunningVllmEndpoint(config, modelId);
|
|
269
|
+
if (existing) {
|
|
270
|
+
endpointUrl = existing.endpointUrl;
|
|
271
|
+
console.log(chalk.dim(` Reusing running vLLM endpoint for ${modelId}: ${endpointUrl}\n`));
|
|
272
|
+
} else {
|
|
273
|
+
// Auto-launching vLLM reuses rest's --max-cost/--persistent, which also
|
|
274
|
+
// apply to Open WebUI below — that's two separately billed deployments,
|
|
275
|
+
// not a shared budget, so say so instead of a silent double spend.
|
|
276
|
+
const { value: webuiMaxCost } = extractFlag(rest, '--max-cost');
|
|
277
|
+
console.log(chalk.bold(`\n Open WebUI needs a model endpoint behind it — no running vLLM endpoint for ${modelId} found.\n`));
|
|
278
|
+
if (webuiMaxCost) {
|
|
279
|
+
console.log(chalk.yellow(` Launching vLLM first with its own --max-cost $${webuiMaxCost} cap (separate from Open WebUI's).`));
|
|
280
|
+
console.log(chalk.yellow(` Total possible spend across both deployments: ~$${(parseFloat(webuiMaxCost) * 2).toFixed(2)}.\n`));
|
|
281
|
+
}
|
|
282
|
+
await serveCommand(config, [alias, ...rest], chalk);
|
|
283
|
+
const launched = await findRunningVllmEndpoint(config, modelId);
|
|
284
|
+
if (!launched) {
|
|
285
|
+
console.error(chalk.red(`\n ✗ vLLM endpoint for ${modelId} did not come up — see output above.\n`));
|
|
286
|
+
console.error(chalk.dim(` Once it's ready, run: badgr serve openwebui --connect <endpoint-url>\n`));
|
|
287
|
+
process.exitCode = 1;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
endpointUrl = launched.endpointUrl;
|
|
291
|
+
launchedVllmDepId = launched.id;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const overrides = parseTemplateOverrides(rest);
|
|
296
|
+
// User-supplied --env intentionally wins over the auto-wired connection env.
|
|
297
|
+
overrides.env = {
|
|
298
|
+
OPENAI_API_BASE_URL: endpointUrl,
|
|
299
|
+
OPENAI_API_KEY: config.apiKey || 'sk-local',
|
|
300
|
+
...overrides.env,
|
|
301
|
+
};
|
|
302
|
+
const flags = buildTemplateFlags(TEMPLATE_MAP.openwebui, overrides);
|
|
303
|
+
await serveCommand(config, flags, chalk);
|
|
304
|
+
|
|
305
|
+
if (process.exitCode && launchedVllmDepId) {
|
|
306
|
+
console.error(chalk.yellow(`\n Open WebUI failed to start. The vLLM endpoint it was connecting to is still running and billing separately.`));
|
|
307
|
+
console.error(chalk.dim(` Stop it with: badgr down ${launchedVllmDepId}\n`));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
197
311
|
export async function serveCommand(config, args, chalk) {
|
|
198
312
|
// `badgr serve template <name> [flags]` — expand template defaults then re-dispatch
|
|
199
313
|
if (args[0] === 'template') {
|
|
@@ -216,14 +330,21 @@ export async function serveCommand(config, args, chalk) {
|
|
|
216
330
|
return serveCommand(config, expandedArgs, chalk);
|
|
217
331
|
}
|
|
218
332
|
|
|
333
|
+
// `badgr serve openwebui|open-webui [flags]` — a chat UI that connects to a
|
|
334
|
+
// model endpoint (usually vLLM). See serveOpenWebUICommand.
|
|
335
|
+
if (args[0] && ['openwebui', 'open-webui'].includes(args[0].toLowerCase())) {
|
|
336
|
+
return serveOpenWebUICommand(config, args.slice(1), chalk);
|
|
337
|
+
}
|
|
338
|
+
|
|
219
339
|
if (args.includes('--list-aliases')) {
|
|
220
|
-
console.log(chalk.bold('\
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
console.log(` ${chalk.cyan(alias.padEnd(16))} ${spec.model_id}`);
|
|
224
|
-
console.log(` ${''.padEnd(16)} ${chalk.dim(spec.description)}`);
|
|
340
|
+
console.log(chalk.bold('\nTested model routes:\n'));
|
|
341
|
+
for (const alias of Object.keys(BLESSED_VLLM_MODELS)) {
|
|
342
|
+
console.log(` - ${chalk.cyan(alias)}`);
|
|
225
343
|
}
|
|
226
344
|
console.log();
|
|
345
|
+
console.log(chalk.dim('You can also try a Hugging Face model ID:'));
|
|
346
|
+
console.log(chalk.dim(' badgr serve Qwen/Qwen2.5-7B-Instruct --max-cost 10'));
|
|
347
|
+
console.log();
|
|
227
348
|
return;
|
|
228
349
|
}
|
|
229
350
|
|
|
@@ -314,50 +435,60 @@ export async function serveCommand(config, args, chalk) {
|
|
|
314
435
|
|
|
315
436
|
const effectiveTier = normalizeTier(flags.tier);
|
|
316
437
|
|
|
438
|
+
// Header fields + trailing note (shown after Max cost) per serve mode.
|
|
439
|
+
// Built as [label, value] pairs so every mode renders through one aligned
|
|
440
|
+
// printer instead of four hand-spaced copies.
|
|
441
|
+
let title;
|
|
442
|
+
const headerLines = [];
|
|
443
|
+
let trailingNote = null;
|
|
444
|
+
let sizeProfile = null; // { label, vram } — only shown when GPU sizing was inferred, not chosen
|
|
445
|
+
|
|
317
446
|
if (isLlamaCpp) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (
|
|
324
|
-
if (gpu === 'AUTO') {
|
|
325
|
-
const prof = _inferGgufProfile(flags.hfFile);
|
|
326
|
-
console.log();
|
|
327
|
-
console.log(` ${chalk.bold('Estimated workload:')} ${prof.label}`);
|
|
328
|
-
console.log(` ${chalk.bold('Estimated minimum VRAM:')} ${prof.vram}`);
|
|
329
|
-
}
|
|
447
|
+
title = flags.hfRepo;
|
|
448
|
+
headerLines.push(['Route', 'best-effort Hugging Face model']);
|
|
449
|
+
headerLines.push(['Mode', 'OpenAI-compatible endpoint (llama.cpp)']);
|
|
450
|
+
headerLines.push(['File', flags.hfFile]);
|
|
451
|
+
trailingNote = 'Badgr will try a compatible route.';
|
|
452
|
+
if (gpu === 'AUTO') sizeProfile = _inferGgufProfile(flags.hfFile);
|
|
330
453
|
} else if (customImage) {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
454
|
+
title = 'custom container';
|
|
455
|
+
headerLines.push(['Mode', 'custom server']);
|
|
456
|
+
trailingNote = 'Badgr manages runtime, logs, caps, teardown, and receipts.\n Your container owns the app behavior.';
|
|
457
|
+
} else if (vllmAlias) {
|
|
458
|
+
title = model;
|
|
459
|
+
headerLines.push(['Route', 'tested']);
|
|
460
|
+
headerLines.push(['Mode', 'OpenAI-compatible endpoint']);
|
|
336
461
|
} else {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
343
|
-
console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
|
|
344
|
-
if (flags.task) console.log(` ${chalk.bold('Task:')} ${flags.task}`);
|
|
345
|
-
if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
|
|
346
|
-
|
|
347
|
-
if (gpu === 'AUTO') {
|
|
348
|
-
const prof = _inferServeProfile(effectiveModel);
|
|
349
|
-
console.log();
|
|
350
|
-
console.log(` ${chalk.bold('Estimated workload:')} ${prof.label}`);
|
|
351
|
-
console.log(` ${chalk.bold('Estimated minimum VRAM:')} ${prof.vram}`);
|
|
352
|
-
}
|
|
462
|
+
title = effectiveModel;
|
|
463
|
+
headerLines.push(['Route', 'best-effort Hugging Face model']);
|
|
464
|
+
headerLines.push(['Mode', 'OpenAI-compatible endpoint']);
|
|
465
|
+
trailingNote = 'Badgr will try a compatible route.';
|
|
466
|
+
if (gpu === 'AUTO') sizeProfile = _inferServeProfile(effectiveModel);
|
|
353
467
|
}
|
|
354
|
-
if (flags.
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
468
|
+
if (flags.gpu) headerLines.push(['GPU', gpuLabel]);
|
|
469
|
+
if (flags.task) headerLines.push(['Task', flags.task]);
|
|
470
|
+
if (flags.env?.length) headerLines.push(['Env', flags.env.join(', ')]);
|
|
471
|
+
|
|
472
|
+
console.log(chalk.bold(`\n⚡ Serving ${title}\n`));
|
|
473
|
+
const labelWidth = Math.max(...headerLines.map(([label]) => label.length)) + 1;
|
|
474
|
+
for (const [label, value] of headerLines) {
|
|
475
|
+
console.log(` ${chalk.bold(`${label}:`.padEnd(labelWidth + 1))}${value}`);
|
|
476
|
+
}
|
|
477
|
+
if (sizeProfile) {
|
|
478
|
+
console.log();
|
|
479
|
+
console.log(` ${chalk.bold('Estimated workload:')} ${sizeProfile.label}`);
|
|
480
|
+
console.log(` ${chalk.bold('Estimated minimum VRAM:')} ${sizeProfile.vram}`);
|
|
481
|
+
}
|
|
482
|
+
console.log(` ${chalk.bold('Max cost:')} ${flags.maxCost ? `$${flags.maxCost.toFixed(2)}` : chalk.dim('none')}`);
|
|
483
|
+
console.log(` ${chalk.bold('Auto-stop:')} ${flags.maxCost ? 'enabled' : chalk.yellow('disabled — stop manually with badgr down')}`);
|
|
484
|
+
if (trailingNote) {
|
|
485
|
+
console.log();
|
|
486
|
+
console.log(chalk.dim(` ${trailingNote}`));
|
|
358
487
|
}
|
|
359
488
|
console.log();
|
|
360
|
-
|
|
489
|
+
|
|
490
|
+
const STAGE_TOTAL = 5;
|
|
491
|
+
let stageN = 1;
|
|
361
492
|
|
|
362
493
|
// ── Duplicate check ────────────────────────────────────────────────────────
|
|
363
494
|
if (config.apiKey) {
|
|
@@ -469,6 +600,11 @@ export async function serveCommand(config, args, chalk) {
|
|
|
469
600
|
createdAt: new Date().toISOString(),
|
|
470
601
|
});
|
|
471
602
|
|
|
603
|
+
console.log(chalk.dim(_stageDone(stageN, STAGE_TOTAL, 'Finding a working route...')));
|
|
604
|
+
stageN++;
|
|
605
|
+
console.log(chalk.dim(_stageDone(stageN, STAGE_TOTAL, 'Starting runtime...')));
|
|
606
|
+
stageN++;
|
|
607
|
+
|
|
472
608
|
// ── Fix 7: never fall back to config.baseUrl for endpoint health check ─────
|
|
473
609
|
const endpointUrl = dep.endpoint_url || dep.openai_base_url;
|
|
474
610
|
if (!endpointUrl) {
|
|
@@ -499,18 +635,48 @@ export async function serveCommand(config, args, chalk) {
|
|
|
499
635
|
resolvedHealthPath = _detectHealthPath(customImage); // '/system_stats' for comfyui, null otherwise
|
|
500
636
|
}
|
|
501
637
|
|
|
638
|
+
// Gated-model guidance is only shown when it's actually needed — on failure —
|
|
639
|
+
// not up front, so common launches stay short and uncluttered.
|
|
640
|
+
const gatedModelId = isLlamaCpp ? flags.hfRepo : effectiveModel;
|
|
641
|
+
const gatedHintNeeded = isLikelyGatedModel(gatedModelId) && !envObjHasHfToken(flags.env);
|
|
642
|
+
|
|
643
|
+
// Shared reporting for "deployment failed to start" — hit from both the
|
|
644
|
+
// pre-poll status check and the waitForEndpoint poll loop below.
|
|
645
|
+
function reportDeployFailure(failReason, failureInfo = {}) {
|
|
646
|
+
console.error(formatCliError('HEALTH_CHECK_DEPLOY_FAILED', {
|
|
647
|
+
deploymentId: dep.deployment_id,
|
|
648
|
+
failReason,
|
|
649
|
+
}, chalk));
|
|
650
|
+
_printFailureClass(chalk, { failure_class: failureInfo.failureClass, next_action: failureInfo.nextAction });
|
|
651
|
+
if (gatedHintNeeded) {
|
|
652
|
+
const rerun = args.join(' ');
|
|
653
|
+
const retryCmd = rerun.includes('HF_TOKEN=') ? rerun : `${rerun} --env HF_TOKEN=$HF_TOKEN`;
|
|
654
|
+
console.error();
|
|
655
|
+
console.error(chalk.yellow(' This model may require Hugging Face access.'));
|
|
656
|
+
console.error(chalk.dim(' Retry:'));
|
|
657
|
+
console.error(chalk.dim(` badgr serve ${retryCmd}`));
|
|
658
|
+
}
|
|
659
|
+
updateReceipt(rcptId, { status: 'failed', failReason });
|
|
660
|
+
process.exitCode = 1;
|
|
661
|
+
}
|
|
662
|
+
|
|
502
663
|
// ── Health check ──────────────────────────────────────────────────────────
|
|
664
|
+
const loadingStageN = stageN; // "Loading model..."
|
|
665
|
+
const healthStageN = stageN + 1; // "Checking endpoint health..."
|
|
666
|
+
const readyStageN = stageN + 2; // "Ready"
|
|
667
|
+
|
|
503
668
|
let endpointReady = false;
|
|
504
669
|
if (flags.noWait) {
|
|
505
|
-
console.log(chalk.yellow('
|
|
670
|
+
console.log(chalk.dim(_stage(loadingStageN, STAGE_TOTAL, 'Loading model...')) + chalk.yellow(' (skipped — --no-wait)'));
|
|
671
|
+
console.log(chalk.dim(_stage(healthStageN, STAGE_TOTAL, 'Checking endpoint health...')) + chalk.yellow(' (skipped — --no-wait)'));
|
|
672
|
+
console.log(chalk.yellow(_stage(readyStageN, STAGE_TOTAL, 'Not confirmed ready — check badgr logs') + `\n`));
|
|
506
673
|
} else if (resolvedHealthPath === null) {
|
|
674
|
+
console.log(chalk.dim(_stage(loadingStageN, STAGE_TOTAL, 'Loading model...')));
|
|
507
675
|
console.log(chalk.yellow(
|
|
508
|
-
|
|
676
|
+
_stage(healthStageN, STAGE_TOTAL, 'Checking endpoint health...') +
|
|
677
|
+
' (skipped — custom image; add --health-path /your-readiness-path to enable)\n'
|
|
509
678
|
));
|
|
510
679
|
} else {
|
|
511
|
-
if (resolvedHealthPath !== '/models') {
|
|
512
|
-
process.stdout.write(chalk.dim(` Checking ${resolvedHealthPath} for readiness…\n`));
|
|
513
|
-
}
|
|
514
680
|
// Check deployment status once before starting the 5-min wait
|
|
515
681
|
try {
|
|
516
682
|
const latest = await callApi(`/deployments/${dep.deployment_id}`, {
|
|
@@ -519,33 +685,35 @@ export async function serveCommand(config, args, chalk) {
|
|
|
519
685
|
timeoutMs: 10_000,
|
|
520
686
|
});
|
|
521
687
|
if (['failed', 'terminated', 'error'].includes(latest.status)) {
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
}, chalk));
|
|
526
|
-
updateReceipt(rcptId, { status: 'failed', failReason: latest.error || latest.status });
|
|
527
|
-
process.exitCode = 1;
|
|
688
|
+
reportDeployFailure(latest.error || latest.status, {
|
|
689
|
+
failureClass: latest.failure_class ?? null, nextAction: latest.next_action ?? null,
|
|
690
|
+
});
|
|
528
691
|
return;
|
|
529
692
|
}
|
|
530
693
|
} catch {
|
|
531
694
|
// status check failed — proceed with endpoint poll anyway
|
|
532
695
|
}
|
|
533
696
|
|
|
534
|
-
const
|
|
535
|
-
|
|
697
|
+
const loadingStageLine = _stage(loadingStageN, STAGE_TOTAL, 'Loading model...');
|
|
698
|
+
const healthResult = await waitForEndpoint(
|
|
699
|
+
dep.deployment_id, config, VLLM_SERVE_WAIT_MS, chalk, resolvedHealthPath,
|
|
700
|
+
{ costPerHour: dep.cost_per_hour || 0, maxCost: flags.maxCost || null, stageLine: loadingStageLine },
|
|
701
|
+
);
|
|
536
702
|
|
|
537
703
|
if (healthResult.depFailed) {
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
}, chalk));
|
|
542
|
-
updateReceipt(rcptId, { status: 'failed', failReason: healthResult.failReason });
|
|
543
|
-
process.exitCode = 1;
|
|
704
|
+
reportDeployFailure(healthResult.failReason, {
|
|
705
|
+
failureClass: healthResult.failureClass, nextAction: healthResult.nextAction,
|
|
706
|
+
});
|
|
544
707
|
return;
|
|
545
708
|
}
|
|
546
709
|
|
|
547
710
|
endpointReady = healthResult.ready;
|
|
548
|
-
if (
|
|
711
|
+
if (endpointReady) {
|
|
712
|
+
console.log(chalk.dim(_stage(healthStageN, STAGE_TOTAL, 'Checking endpoint health...')));
|
|
713
|
+
console.log(chalk.green(_stage(readyStageN, STAGE_TOTAL, 'Ready')));
|
|
714
|
+
} else {
|
|
715
|
+
updateReceipt(rcptId, { status: 'health_check_timeout' });
|
|
716
|
+
}
|
|
549
717
|
}
|
|
550
718
|
|
|
551
719
|
// ── Custom-node validation (ComfyUI) ─────────────────────────────────────
|
|
@@ -570,8 +738,6 @@ export async function serveCommand(config, args, chalk) {
|
|
|
570
738
|
console.log();
|
|
571
739
|
}
|
|
572
740
|
|
|
573
|
-
const serveRate = dep.cost_per_hour || 0;
|
|
574
|
-
|
|
575
741
|
console.log(` ${chalk.bold('Base URL:')} ${chalk.cyan(endpointUrl)}`);
|
|
576
742
|
if (isLlamaCpp) {
|
|
577
743
|
console.log(` ${chalk.bold('HF Repo:')} ${flags.hfRepo}`);
|
|
@@ -579,23 +745,20 @@ export async function serveCommand(config, args, chalk) {
|
|
|
579
745
|
}
|
|
580
746
|
else if (dep.model || effectiveModel) console.log(` ${chalk.bold('Model:')} ${dep.model || effectiveModel}`);
|
|
581
747
|
if (customImage) console.log(` ${chalk.bold('Image:')} ${customImage}`);
|
|
582
|
-
console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
|
|
583
|
-
if (serveRate > 0) console.log(` ${chalk.bold('Rate:')} $${serveRate.toFixed(2)}/hr`);
|
|
584
748
|
if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
|
|
585
|
-
console.log(` ${chalk.bold('
|
|
586
|
-
console.log(` ${chalk.bold('Logs:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
|
|
749
|
+
console.log(` ${chalk.bold('Logs:')} badgr logs ${dep.deployment_id}`);
|
|
587
750
|
console.log(` ${chalk.bold('Stop billing:')} ${chalk.cyan(`badgr down ${dep.deployment_id}`)}`);
|
|
751
|
+
console.log(` ${chalk.bold('Receipt:')} badgr receipts ${rcptId}`);
|
|
588
752
|
console.log();
|
|
589
|
-
|
|
590
|
-
if (serveRate > HIGH_RATE_THRESHOLD && !flags.maxCost) {
|
|
591
|
-
console.log(chalk.yellow(` Selected capacity rate: $${serveRate.toFixed(2)}/hr`));
|
|
592
|
-
console.log(chalk.dim(' Tip: use --max-cost to enforce a hard ceiling.\n'));
|
|
593
|
-
}
|
|
594
753
|
console.log(chalk.dim(' Billing continues until you run: ') + chalk.cyan(`badgr down ${dep.deployment_id}`));
|
|
595
754
|
|
|
596
755
|
if (endpointReady && !customImage) {
|
|
597
756
|
const keySnip = config.apiKey?.slice(0, 4) || 'sk-...';
|
|
598
757
|
const sdkModel = isLlamaCpp ? 'default' : (dep.model || effectiveModel);
|
|
758
|
+
console.log(` ${chalk.bold('Test with curl:')}`);
|
|
759
|
+
console.log(chalk.dim(` curl ${endpointUrl}/chat/completions \\`));
|
|
760
|
+
console.log(chalk.dim(` -H "Authorization: Bearer ${keySnip}..." -H "Content-Type: application/json" \\`));
|
|
761
|
+
console.log(chalk.dim(` -d '{"model":"${sdkModel}","messages":[{"role":"user","content":"Hello"}]}'`));
|
|
599
762
|
console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
|
|
600
763
|
console.log(chalk.dim(` from openai import OpenAI`));
|
|
601
764
|
console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${keySnip}...")`));
|
package/src/commands/train.js
CHANGED
|
@@ -10,6 +10,7 @@ import { requireApiKey } from '../config.js';
|
|
|
10
10
|
import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
|
|
11
11
|
import { normalizeTier, callWithFallback } from '../fallback.js';
|
|
12
12
|
import { monitorBatchJob, fmtRuntime } from '../batch.js';
|
|
13
|
+
import { pollJobUntilTerminal, renderJobClosingBlock } from '../progress.js';
|
|
13
14
|
|
|
14
15
|
const MAX_CONFIG_B = 512 * 1024; // 512 KB config limit
|
|
15
16
|
|
|
@@ -239,35 +240,29 @@ export async function trainLoraCommand(config, args, chalk) {
|
|
|
239
240
|
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
240
241
|
console.log(chalk.dim('\n Polling for completion — Ctrl+C to detach (GPU keeps running)\n'));
|
|
241
242
|
|
|
242
|
-
// Poll until complete
|
|
243
|
-
const startMs = Date.now();
|
|
244
243
|
const maxMs = maxRuntime * 60 * 1000;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
if (
|
|
264
|
-
console.error(chalk.red(`\n\n ✗ Training failed: ${detail.error_code || ''} — ${detail.error_message || ''}\n`));
|
|
265
|
-
process.exitCode = 1;
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
244
|
+
const { outcome, detail } = await pollJobUntilTerminal(callApi, config, job.job_id, { chalk, maxMs });
|
|
245
|
+
|
|
246
|
+
if (outcome === 'polling_failed') {
|
|
247
|
+
console.error(chalk.red(`\n\n ✗ Lost contact with Badgr — could not confirm job status.\n`));
|
|
248
|
+
console.error(chalk.dim(` badgr status\n badgr logs ${job.job_id}\n`));
|
|
249
|
+
process.exitCode = 1;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (outcome === 'timed_out') {
|
|
253
|
+
console.error(chalk.yellow('\n\n Training still running — detached. Check status:\n'));
|
|
254
|
+
console.error(chalk.dim(` badgr status\n`));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (outcome === 'completed') {
|
|
259
|
+
const out = detail.output || {};
|
|
260
|
+
if (out.adapter_url) console.log(`\n ${chalk.bold('Adapter:')} ${out.adapter_url}`);
|
|
261
|
+
if (out.checkpoint_url) console.log(` ${chalk.bold('Checkpoint:')} ${out.checkpoint_url}`);
|
|
262
|
+
if (out.warning) console.log(chalk.yellow(` ${out.warning}`));
|
|
268
263
|
}
|
|
269
|
-
console.
|
|
270
|
-
|
|
264
|
+
console.log(renderJobClosingBlock(chalk, detail, rcptId));
|
|
265
|
+
if (outcome === 'failed') process.exitCode = 1;
|
|
271
266
|
}
|
|
272
267
|
|
|
273
268
|
export async function trainCommand(config, args, chalk) {
|