badgr-cli 1.0.31 → 1.0.34
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/HOW_IT_WORKS.md +4 -4
- package/README.md +57 -27
- package/package.json +14 -6
- package/src/api.js +138 -20
- package/src/badgr.js +81 -37
- package/src/commands/billing.js +93 -0
- package/src/commands/capacity.js +111 -0
- package/src/commands/down.js +26 -23
- package/src/commands/login.js +23 -7
- package/src/commands/logs.js +57 -5
- package/src/commands/models.js +25 -6
- package/src/commands/receipts.js +23 -4
- package/src/commands/run.js +551 -86
- package/src/commands/serve.js +343 -66
- package/src/commands/status.js +35 -48
- package/src/commands/test-run.js +240 -0
- package/src/commands/up.js +32 -26
- package/src/config.js +49 -4
- package/src/errors.js +299 -0
- package/src/fallback.js +219 -0
- package/src/router.js +17 -73
- package/src/store.js +10 -1
- package/tests/commands.test.js +246 -2
- package/tests/config.test.js +24 -1
- package/tests/errors.test.js +130 -0
- package/tests/launch-readiness.test.js +326 -0
- package/tests/router.test.js +9 -68
- package/tests/run-lifecycle.test.js +508 -0
- package/tests/serve-lifecycle.test.js +499 -0
- package/tests/store.test.js +41 -1
package/src/commands/serve.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { requireApiKey } from '../config.js';
|
|
2
2
|
import { callApi } from '../api.js';
|
|
3
|
-
import { addDeployment, addReceipt, generateReceiptId } from '../store.js';
|
|
3
|
+
import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../store.js';
|
|
4
|
+
import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
|
|
5
|
+
import { formatCliError } from '../errors.js';
|
|
4
6
|
|
|
5
7
|
/**
|
|
8
|
+
* badgr serve meta-llama/Llama-3.1-8B-Instruct
|
|
6
9
|
* badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S
|
|
10
|
+
* badgr serve BAAI/bge-large-en-v1.5 --task embed
|
|
11
|
+
* badgr serve --image ghcr.io/my-org/diffusers-api:latest --gpu L40S --env MODEL_ID=flux
|
|
7
12
|
*
|
|
8
|
-
*
|
|
9
|
-
* "Endpoint ready", and returns an OpenAI-compatible base URL.
|
|
10
|
-
* Stop billing with `badgr down <id>`.
|
|
13
|
+
* GPU defaults to "AUTO" — backend infers from model size.
|
|
11
14
|
*/
|
|
12
15
|
export function parseServeArgs(args) {
|
|
13
16
|
const flags = {};
|
|
@@ -15,82 +18,272 @@ export function parseServeArgs(args) {
|
|
|
15
18
|
let i = 0;
|
|
16
19
|
while (i < args.length) {
|
|
17
20
|
if (args[i] === '--gpu') { flags.gpu = args[++i]; i++; continue; }
|
|
21
|
+
if (args[i] === '--image') { flags.image = args[++i]; i++; continue; }
|
|
22
|
+
if (args[i] === '--task') { flags.task = args[++i]; i++; continue; }
|
|
18
23
|
if (args[i] === '--count') { flags.count = parseInt(args[++i], 10); i++; continue; }
|
|
19
24
|
if (args[i] === '--region') { flags.region = args[++i]; i++; continue; }
|
|
25
|
+
if (args[i] === '--tier') { flags.tier = args[++i]; i++; continue; }
|
|
20
26
|
if (args[i] === '--max-price') { flags.maxPrice = parseFloat(args[++i]); i++; continue; }
|
|
21
27
|
if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
|
|
22
|
-
if (args[i] === '--no-wait')
|
|
28
|
+
if (args[i] === '--no-wait') { flags.noWait = true; i++; continue; }
|
|
29
|
+
if (args[i] === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
|
|
30
|
+
if (args[i] === '--health-path') { flags.healthPath = args[++i]; i++; continue; }
|
|
31
|
+
if (args[i] === '--check-nodes') { flags.checkNodes = args[++i]; i++; continue; }
|
|
32
|
+
// All three aliases map to noMarketplaceFallback
|
|
33
|
+
if (args[i] === '--no-fallback' ||
|
|
34
|
+
args[i] === '--strict-capacity' ||
|
|
35
|
+
args[i] === '--no-expanded-search') { flags.noMarketplaceFallback = true; i++; continue; }
|
|
36
|
+
if (args[i] === '--env') {
|
|
37
|
+
const kv = args[++i]; i++;
|
|
38
|
+
if (!flags.env) flags.env = [];
|
|
39
|
+
flags.env.push(kv);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
23
42
|
positional.push(args[i++]);
|
|
24
43
|
}
|
|
25
44
|
const model = positional[0] || null;
|
|
26
45
|
return { model, flags };
|
|
27
46
|
}
|
|
28
47
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
48
|
+
function parseEnvFlag(envList) {
|
|
49
|
+
const obj = {};
|
|
50
|
+
for (const kv of (envList || [])) {
|
|
51
|
+
const idx = kv.indexOf('=');
|
|
52
|
+
if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
|
|
53
|
+
}
|
|
54
|
+
return obj;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Mirror of backend workload_profile.py infer_profile_from_model — for pre-flight display.
|
|
58
|
+
function _inferServeProfile(modelName) {
|
|
59
|
+
const s = modelName.toLowerCase();
|
|
60
|
+
const moe = s.match(/(\d+)x(\d+)b/);
|
|
61
|
+
let paramsB;
|
|
62
|
+
if (moe) {
|
|
63
|
+
paramsB = parseInt(moe[1]) * parseInt(moe[2]);
|
|
64
|
+
} else {
|
|
65
|
+
const m = s.match(/(\d+)b/);
|
|
66
|
+
paramsB = m ? parseInt(m[1]) : null;
|
|
67
|
+
}
|
|
68
|
+
if (paramsB === null) return { label: 'inference (7B–8B model)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] };
|
|
69
|
+
if (paramsB <= 9) return { label: 'inference (7B–8B model)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] };
|
|
70
|
+
if (paramsB <= 35) return { label: 'inference (30B–34B model)', vram: '40+ GB', gpus: ['A6000', 'L40S', 'A100'] };
|
|
71
|
+
return { label: 'inference (70B+ model)', vram: '80+ GB', gpus: ['H100', 'A100'] };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function _serveStageLabel(elapsedSec, healthPath = '/models') {
|
|
75
|
+
if (healthPath === '/models') {
|
|
76
|
+
if (elapsedSec < 45) return 'Starting vLLM…';
|
|
77
|
+
if (elapsedSec < 150) return 'Downloading model…';
|
|
78
|
+
return 'Waiting for /v1/models…';
|
|
79
|
+
}
|
|
80
|
+
if (elapsedSec < 30) return 'Starting container…';
|
|
81
|
+
if (elapsedSec < 120) return 'Container starting…';
|
|
82
|
+
return `Waiting for ${healthPath}…`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function _detectHealthPath(image) {
|
|
86
|
+
if (!image) return null;
|
|
87
|
+
return image.toLowerCase().includes('comfyui') ? '/system_stats' : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Poll the model endpoint until /models returns 200, or timeout.
|
|
92
|
+
* Also checks deployment status on each iteration — fails fast if the dep is dead.
|
|
93
|
+
* Returns { ready: boolean, timedOut: boolean, depFailed: boolean, failReason?: string }
|
|
94
|
+
*/
|
|
95
|
+
async function waitForEndpoint(endpointUrl, deploymentId, config, timeoutMs = 5 * 60 * 1000, chalk, healthPath = '/models') {
|
|
96
|
+
const startMs = Date.now();
|
|
97
|
+
const deadline = startMs + timeoutMs;
|
|
35
98
|
|
|
36
99
|
while (Date.now() < deadline) {
|
|
37
|
-
|
|
100
|
+
// Check deployment status first — fail fast on OOM/crash before waiting more
|
|
101
|
+
try {
|
|
102
|
+
const dep = await callApi(`/deployments/${deploymentId}`, {
|
|
103
|
+
apiKey: config.apiKey,
|
|
104
|
+
baseUrl: config.baseUrl,
|
|
105
|
+
timeoutMs: 10_000,
|
|
106
|
+
});
|
|
107
|
+
if (['failed', 'terminated', 'error', 'stopped'].includes(dep.status)) {
|
|
108
|
+
process.stdout.write('\n');
|
|
109
|
+
return { ready: false, timedOut: false, depFailed: true, failReason: dep.error || dep.status };
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// status check failed — continue to endpoint check
|
|
113
|
+
}
|
|
114
|
+
|
|
38
115
|
try {
|
|
39
|
-
const res = await fetch(
|
|
40
|
-
if (res.ok) return true;
|
|
116
|
+
const res = await fetch(`${endpointUrl}${healthPath}`, { signal: AbortSignal.timeout(8000) });
|
|
117
|
+
if (res.ok) { process.stdout.write('\n'); return { ready: true, timedOut: false, depFailed: false }; }
|
|
41
118
|
} catch {
|
|
42
|
-
//
|
|
119
|
+
// still starting
|
|
43
120
|
}
|
|
44
|
-
|
|
121
|
+
|
|
122
|
+
const elapsed = Math.round((Date.now() - startMs) / 1000);
|
|
45
123
|
process.stdout.write(
|
|
46
|
-
`\r ${chalk.dim(
|
|
124
|
+
`\r ${chalk.dim(_serveStageLabel(elapsed, healthPath) + ` (${elapsed}s)`)} `
|
|
47
125
|
);
|
|
48
126
|
await new Promise(r => setTimeout(r, 8000));
|
|
49
127
|
}
|
|
128
|
+
|
|
50
129
|
process.stdout.write('\n');
|
|
51
|
-
return false;
|
|
130
|
+
return { ready: false, timedOut: true, depFailed: false };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function validateComfyNodes(baseUrl, nodeList, chalk) {
|
|
134
|
+
const nodes = nodeList.split(',').map(n => n.trim()).filter(Boolean);
|
|
135
|
+
if (!nodes.length) return;
|
|
136
|
+
const objectInfoUrl = baseUrl.replace(/\/v1\/?$/, '') + '/object_info';
|
|
137
|
+
console.log(chalk.dim(`\n Checking custom nodes via ${objectInfoUrl}…`));
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch(objectInfoUrl, { signal: AbortSignal.timeout(15_000) });
|
|
140
|
+
if (!res.ok) {
|
|
141
|
+
console.log(chalk.yellow(` ⚠ Could not verify nodes (HTTP ${res.status})`));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const available = await res.json();
|
|
145
|
+
const found = nodes.filter(n => Object.prototype.hasOwnProperty.call(available, n));
|
|
146
|
+
const missing = nodes.filter(n => !Object.prototype.hasOwnProperty.call(available, n));
|
|
147
|
+
if (found.length) console.log(chalk.green(` ✓ Nodes confirmed: ${found.join(', ')}`));
|
|
148
|
+
if (missing.length) {
|
|
149
|
+
console.log(chalk.yellow(` ⚠ Nodes not found: ${missing.join(', ')}`));
|
|
150
|
+
console.log(chalk.dim(' These may not be installed in this image.'));
|
|
151
|
+
}
|
|
152
|
+
} catch (err) {
|
|
153
|
+
console.log(chalk.yellow(` ⚠ Node check failed: ${err.message}`));
|
|
154
|
+
}
|
|
52
155
|
}
|
|
53
156
|
|
|
157
|
+
// Known badgr serve flags — used to detect broken shell line continuation.
|
|
158
|
+
const _KNOWN_SERVE_FLAGS = new Set([
|
|
159
|
+
'--gpu', '--image', '--task', '--count', '--region', '--tier', '--max-price',
|
|
160
|
+
'--name', '--no-wait', '--max-cost', '--health-path', '--check-nodes',
|
|
161
|
+
'--no-fallback', '--strict-capacity', '--no-expanded-search', '--env',
|
|
162
|
+
]);
|
|
163
|
+
|
|
54
164
|
export async function serveCommand(config, args, chalk) {
|
|
55
165
|
const { model, flags } = parseServeArgs(args);
|
|
166
|
+
const customImage = flags.image || null;
|
|
56
167
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
168
|
+
// Detect flags that ended up as positional args due to broken shell line continuation
|
|
169
|
+
// (e.g. `\ ` with a trailing space instead of `\<newline>`).
|
|
170
|
+
// After parseServeArgs, only the model name should be in positional. Any extra token
|
|
171
|
+
// that matches a known badgr flag name was not parsed — likely a syntax mistake.
|
|
172
|
+
const servePositional = args.filter(a => !a.startsWith('-'));
|
|
173
|
+
const misplaced = servePositional.slice(1).filter(a => _KNOWN_SERVE_FLAGS.has(a));
|
|
174
|
+
if (misplaced.length > 0) {
|
|
175
|
+
console.error(chalk.red(`\n ✗ These look like badgr flags but were treated as arguments:`));
|
|
176
|
+
console.error(chalk.red(` ${misplaced.join(', ')}`));
|
|
177
|
+
console.error(chalk.dim(''));
|
|
178
|
+
console.error(chalk.dim(' This usually means a line continuation has a trailing space.'));
|
|
179
|
+
console.error(chalk.dim(' Use a single line, or end each continued line with \\ and no space after:'));
|
|
180
|
+
console.error(chalk.dim(''));
|
|
181
|
+
console.error(chalk.dim(' badgr serve meta-llama/Llama-3.1-8B-Instruct \\'));
|
|
182
|
+
console.error(chalk.dim(' --gpu L40S --max-cost 10'));
|
|
183
|
+
console.error(chalk.dim(''));
|
|
184
|
+
process.exitCode = 1;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!model && !customImage) {
|
|
189
|
+
console.error(chalk.red('Usage: badgr serve <model>'));
|
|
190
|
+
console.error(chalk.red(' badgr serve meta-llama/Llama-3.1-8B-Instruct'));
|
|
191
|
+
console.error(chalk.red(' badgr serve --image ghcr.io/my-org/api:latest --gpu L40S'));
|
|
60
192
|
return;
|
|
61
193
|
}
|
|
62
194
|
|
|
63
195
|
requireApiKey(config);
|
|
64
196
|
|
|
65
|
-
|
|
197
|
+
// ── Validate flags early ───────────────────────────────────────────────────
|
|
198
|
+
if (flags.count !== undefined && (!Number.isFinite(flags.count) || flags.count < 1)) {
|
|
199
|
+
console.error(chalk.red(' ✗ --count must be an integer greater than 0'));
|
|
200
|
+
process.exitCode = 1;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (flags.maxCost !== undefined && (!Number.isFinite(flags.maxCost) || flags.maxCost <= 0)) {
|
|
204
|
+
console.error(chalk.red(' ✗ --max-cost must be a number greater than 0'));
|
|
205
|
+
process.exitCode = 1;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (flags.region !== undefined && !['US', 'EU', 'AU'].includes(flags.region.toUpperCase())) {
|
|
209
|
+
console.error(chalk.red(' ✗ --region must be US, EU, or AU'));
|
|
210
|
+
process.exitCode = 1;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
66
213
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
214
|
+
const envObj = parseEnvFlag(flags.env);
|
|
215
|
+
|
|
216
|
+
const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : (customImage ? 'L40S' : 'AUTO');
|
|
217
|
+
const gpuLabel = gpu === 'AUTO' ? 'auto' : gpu;
|
|
218
|
+
|
|
219
|
+
const effectiveTier = normalizeTier(flags.tier);
|
|
220
|
+
|
|
221
|
+
if (customImage) {
|
|
222
|
+
console.log(chalk.bold('\n⚡ Serving custom container\n'));
|
|
223
|
+
console.log(` ${chalk.bold('Image:')} ${customImage}`);
|
|
224
|
+
console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
|
|
225
|
+
if (flags.task) console.log(` ${chalk.bold('Task:')} ${flags.task}`);
|
|
226
|
+
if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
|
|
227
|
+
} else {
|
|
228
|
+
console.log(chalk.bold('\n⚡ Serving model\n'));
|
|
229
|
+
console.log(` ${chalk.bold('Model:')} ${model}`);
|
|
230
|
+
console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
|
|
231
|
+
if (flags.task) console.log(` ${chalk.bold('Task:')} ${flags.task}`);
|
|
232
|
+
if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
|
|
233
|
+
|
|
234
|
+
if (gpu === 'AUTO') {
|
|
235
|
+
const prof = _inferServeProfile(model);
|
|
236
|
+
console.log();
|
|
237
|
+
console.log(` ${chalk.bold('Estimated workload:')} ${prof.label}`);
|
|
238
|
+
console.log(` ${chalk.bold('Estimated minimum VRAM:')} ${prof.vram}`);
|
|
239
|
+
if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
70
242
|
console.log();
|
|
71
|
-
|
|
243
|
+
process.stdout.write(chalk.dim(' Finding suitable capacity...\n'));
|
|
244
|
+
|
|
245
|
+
const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
|
|
246
|
+
|
|
247
|
+
function buildBody(gpuOverride, tierOverride) {
|
|
248
|
+
return {
|
|
249
|
+
...(model ? { model } : {}),
|
|
250
|
+
...(customImage ? { image: customImage } : {}),
|
|
251
|
+
...(flags.task ? { task: flags.task } : {}),
|
|
252
|
+
gpu: gpuOverride || gpu,
|
|
253
|
+
gpu_count: flags.count || 1,
|
|
254
|
+
...(effectiveRegion ? { region: effectiveRegion } : {}),
|
|
255
|
+
max_price_per_hour: flags.maxPrice,
|
|
256
|
+
name: flags.name,
|
|
257
|
+
tier: tierOverride || effectiveTier,
|
|
258
|
+
...(Object.keys(envObj).length > 0 ? { env: envObj } : {}),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
72
261
|
|
|
73
262
|
let dep;
|
|
74
263
|
try {
|
|
75
|
-
dep = await
|
|
76
|
-
|
|
77
|
-
apiKey: config.apiKey,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
max_price_per_hour: flags.maxPrice,
|
|
85
|
-
name: flags.name,
|
|
86
|
-
},
|
|
87
|
-
});
|
|
264
|
+
dep = await callWithFallback(
|
|
265
|
+
'/serve',
|
|
266
|
+
{ apiKey: config.apiKey, baseUrl: config.baseUrl },
|
|
267
|
+
(tierOverride) => buildBody(undefined, tierOverride),
|
|
268
|
+
effectiveTier,
|
|
269
|
+
chalk,
|
|
270
|
+
{ thing: 'endpoint', cmd: 'badgr serve' },
|
|
271
|
+
{ allowTier2Fallback: !flags.noMarketplaceFallback },
|
|
272
|
+
);
|
|
88
273
|
} catch (err) {
|
|
89
|
-
|
|
274
|
+
if (err.isPaymentRequired) {
|
|
275
|
+
console.error(chalk.yellow(err.message));
|
|
276
|
+
const rerun = `badgr serve ${args.join(' ')}`;
|
|
277
|
+
console.error(chalk.dim(`After payment, rerun:\n ${rerun}\n`));
|
|
278
|
+
process.exitCode = 1;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
// CapacityError message is pre-formatted with chalk
|
|
282
|
+
console.error(err.message);
|
|
283
|
+
process.exitCode = 1;
|
|
90
284
|
return;
|
|
91
285
|
}
|
|
92
286
|
|
|
93
|
-
// Mirror to local store so badgr down/status/receipts work offline
|
|
94
287
|
addDeployment({
|
|
95
288
|
id: dep.deployment_id,
|
|
96
289
|
name: dep.name,
|
|
@@ -98,12 +291,13 @@ export async function serveCommand(config, args, chalk) {
|
|
|
98
291
|
model: dep.model || model,
|
|
99
292
|
gpu: dep.gpu_type,
|
|
100
293
|
count: dep.gpu_count,
|
|
101
|
-
provider: dep.provider,
|
|
102
294
|
status: dep.status,
|
|
103
295
|
endpointUrl: dep.endpoint_url || dep.openai_base_url,
|
|
104
296
|
receiptId: dep.receipt_id,
|
|
105
297
|
createdAt: new Date().toISOString(),
|
|
106
298
|
costPerHour: dep.cost_per_hour || 0,
|
|
299
|
+
providerRoute: dep.provider ?? null,
|
|
300
|
+
tier: dep.tier ?? null,
|
|
107
301
|
});
|
|
108
302
|
|
|
109
303
|
const rcptId = dep.receipt_id || generateReceiptId();
|
|
@@ -111,49 +305,132 @@ export async function serveCommand(config, args, chalk) {
|
|
|
111
305
|
receiptId: rcptId,
|
|
112
306
|
action: 'badgr serve',
|
|
113
307
|
deploymentId: dep.deployment_id,
|
|
114
|
-
provider: dep.provider,
|
|
115
308
|
gpu: dep.gpu_type,
|
|
309
|
+
providerRoute: dep.provider ?? null,
|
|
310
|
+
tier: dep.tier ?? null,
|
|
116
311
|
status: dep.status,
|
|
117
312
|
createdAt: new Date().toISOString(),
|
|
118
313
|
});
|
|
119
314
|
|
|
120
|
-
|
|
315
|
+
// ── Fix 7: never fall back to config.baseUrl for endpoint health check ─────
|
|
316
|
+
const endpointUrl = dep.endpoint_url || dep.openai_base_url;
|
|
317
|
+
if (!endpointUrl) {
|
|
318
|
+
console.error(chalk.red(
|
|
319
|
+
`\n ✗ Deployment ${dep.deployment_id} has no endpoint URL yet.\n` +
|
|
320
|
+
` Run: badgr status ${dep.deployment_id}\n`
|
|
321
|
+
));
|
|
322
|
+
updateReceipt(rcptId, { status: 'no_endpoint_url' });
|
|
323
|
+
process.exitCode = 1;
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
121
326
|
|
|
122
|
-
// ──
|
|
123
|
-
|
|
327
|
+
// ── Determine health check path ───────────────────────────────────────────
|
|
328
|
+
// Priority: explicit --health-path > auto-detect comfyui > /models for vLLM > null (skip)
|
|
329
|
+
let resolvedHealthPath;
|
|
330
|
+
if (flags.healthPath) {
|
|
331
|
+
resolvedHealthPath = flags.healthPath;
|
|
332
|
+
} else if (!customImage) {
|
|
333
|
+
resolvedHealthPath = '/models';
|
|
334
|
+
} else {
|
|
335
|
+
resolvedHealthPath = _detectHealthPath(customImage); // '/system_stats' for comfyui, null otherwise
|
|
336
|
+
}
|
|
124
337
|
|
|
338
|
+
// ── Health check ──────────────────────────────────────────────────────────
|
|
339
|
+
let endpointReady = false;
|
|
125
340
|
if (flags.noWait) {
|
|
126
|
-
console.log(chalk.yellow('\n
|
|
341
|
+
console.log(chalk.yellow('\n Skipped health check (--no-wait)\n'));
|
|
342
|
+
} else if (resolvedHealthPath === null) {
|
|
343
|
+
console.log(chalk.yellow(
|
|
344
|
+
'\n Skipping health check (custom image — add --health-path /your-readiness-path to enable)\n'
|
|
345
|
+
));
|
|
127
346
|
} else {
|
|
128
|
-
|
|
129
|
-
|
|
347
|
+
if (resolvedHealthPath !== '/models') {
|
|
348
|
+
process.stdout.write(chalk.dim(` Checking ${resolvedHealthPath} for readiness…\n`));
|
|
349
|
+
}
|
|
350
|
+
// Check deployment status once before starting the 5-min wait
|
|
351
|
+
try {
|
|
352
|
+
const latest = await callApi(`/deployments/${dep.deployment_id}`, {
|
|
353
|
+
apiKey: config.apiKey,
|
|
354
|
+
baseUrl: config.baseUrl,
|
|
355
|
+
timeoutMs: 10_000,
|
|
356
|
+
});
|
|
357
|
+
if (['failed', 'terminated', 'error'].includes(latest.status)) {
|
|
358
|
+
console.error(formatCliError('HEALTH_CHECK_DEPLOY_FAILED', {
|
|
359
|
+
deploymentId: dep.deployment_id,
|
|
360
|
+
failReason: latest.error || latest.status,
|
|
361
|
+
}, chalk));
|
|
362
|
+
updateReceipt(rcptId, { status: 'failed', failReason: latest.error || latest.status });
|
|
363
|
+
process.exitCode = 1;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
} catch {
|
|
367
|
+
// status check failed — proceed with endpoint poll anyway
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const healthResult = await waitForEndpoint(endpointUrl, dep.deployment_id, config, 5 * 60 * 1000, chalk, resolvedHealthPath);
|
|
130
371
|
process.stdout.write('\n');
|
|
372
|
+
|
|
373
|
+
if (healthResult.depFailed) {
|
|
374
|
+
console.error(formatCliError('HEALTH_CHECK_DEPLOY_FAILED', {
|
|
375
|
+
deploymentId: dep.deployment_id,
|
|
376
|
+
failReason: healthResult.failReason,
|
|
377
|
+
}, chalk));
|
|
378
|
+
updateReceipt(rcptId, { status: 'failed', failReason: healthResult.failReason });
|
|
379
|
+
process.exitCode = 1;
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
endpointReady = healthResult.ready;
|
|
384
|
+
if (!endpointReady) updateReceipt(rcptId, { status: 'health_check_timeout' });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── Custom-node validation (ComfyUI) ─────────────────────────────────────
|
|
388
|
+
if (flags.checkNodes && endpointReady) {
|
|
389
|
+
await validateComfyNodes(endpointUrl, flags.checkNodes, chalk);
|
|
131
390
|
}
|
|
132
391
|
|
|
133
|
-
// ──
|
|
392
|
+
// ── Update receipt with final state ───────────────────────────────────────
|
|
393
|
+
updateReceipt(rcptId, {
|
|
394
|
+
status: endpointReady ? 'ready' : 'starting',
|
|
395
|
+
endpointUrl,
|
|
396
|
+
updatedAt: new Date().toISOString(),
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// ── Result ────────────────────────────────────────────────────────────────
|
|
134
400
|
if (endpointReady) {
|
|
135
|
-
console.log(chalk.green('✓ Endpoint ready\n'));
|
|
401
|
+
console.log(chalk.green('\n ✓ Endpoint ready\n'));
|
|
136
402
|
} else {
|
|
137
|
-
console.log(chalk.yellow('⏳ Endpoint starting
|
|
138
|
-
console.log(chalk.dim(`
|
|
139
|
-
console.log(chalk.dim(`
|
|
140
|
-
console.log(chalk.dim(` curl ${endpointUrl}/models`));
|
|
403
|
+
console.log(chalk.yellow('\n ⏳ Endpoint still starting — model download may still be in progress.\n'));
|
|
404
|
+
console.log(` ${chalk.bold('Stop billing now:')} ${chalk.dim(`badgr down ${dep.deployment_id}`)}`);
|
|
405
|
+
console.log(` ${chalk.bold('Continue watching:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
|
|
141
406
|
console.log();
|
|
142
407
|
}
|
|
143
408
|
|
|
144
|
-
|
|
145
|
-
console.log(` ${chalk.bold('Model:')} ${dep.model || model}`);
|
|
146
|
-
console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
|
|
147
|
-
console.log(` ${chalk.bold('Endpoint:')} ${chalk.cyan(endpointUrl)}`);
|
|
148
|
-
if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
|
|
149
|
-
console.log(`\n ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
409
|
+
const serveRate = dep.cost_per_hour || 0;
|
|
150
410
|
|
|
151
|
-
|
|
152
|
-
|
|
411
|
+
console.log(` ${chalk.bold('Base URL:')} ${chalk.cyan(endpointUrl)}`);
|
|
412
|
+
if (dep.model || model) console.log(` ${chalk.bold('Model:')} ${dep.model || model}`);
|
|
413
|
+
if (customImage) console.log(` ${chalk.bold('Image:')} ${customImage}`);
|
|
414
|
+
console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
|
|
415
|
+
if (serveRate > 0) console.log(` ${chalk.bold('Rate:')} $${serveRate.toFixed(2)}/hr`);
|
|
416
|
+
if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
|
|
417
|
+
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
418
|
+
console.log(` ${chalk.bold('Logs:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
|
|
419
|
+
console.log(` ${chalk.bold('Stop billing:')} ${chalk.cyan(`badgr down ${dep.deployment_id}`)}`);
|
|
420
|
+
console.log();
|
|
421
|
+
|
|
422
|
+
if (serveRate > HIGH_RATE_THRESHOLD && !flags.maxCost) {
|
|
423
|
+
console.log(chalk.yellow(` Selected capacity rate: $${serveRate.toFixed(2)}/hr`));
|
|
424
|
+
console.log(chalk.dim(' Tip: use --max-cost to enforce a hard ceiling.\n'));
|
|
425
|
+
}
|
|
426
|
+
console.log(chalk.dim(' Billing continues until you run: ') + chalk.cyan(`badgr down ${dep.deployment_id}`));
|
|
427
|
+
|
|
428
|
+
if (endpointReady && !customImage) {
|
|
429
|
+
const keySnip = config.apiKey?.slice(0, 4) || 'sk-...';
|
|
430
|
+
console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
|
|
153
431
|
console.log(chalk.dim(` from openai import OpenAI`));
|
|
154
|
-
console.log(chalk.dim(` client = OpenAI(
|
|
432
|
+
console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${keySnip}...")`));
|
|
155
433
|
console.log(chalk.dim(` resp = client.chat.completions.create(model="${dep.model || model}", messages=[...])`));
|
|
434
|
+
console.log();
|
|
156
435
|
}
|
|
157
|
-
|
|
158
|
-
console.log(`\n ${chalk.dim(`Stop billing: badgr down ${dep.deployment_id}`)}\n`);
|
|
159
436
|
}
|
package/src/commands/status.js
CHANGED
|
@@ -2,24 +2,20 @@ import { listDeployments as localDeployments } from '../store.js';
|
|
|
2
2
|
import { listDeployments as apiDeployments } from '../api.js';
|
|
3
3
|
|
|
4
4
|
export async function statusCommand(config, args, chalk) {
|
|
5
|
-
console.log(chalk.bold('\n📊 GPU Status\n'));
|
|
6
|
-
|
|
7
5
|
let deployments = [];
|
|
8
6
|
|
|
9
|
-
// ── Live data from the backend ────────────────────────────────────────────
|
|
10
7
|
if (config.apiKey) {
|
|
11
8
|
try {
|
|
12
9
|
const data = await apiDeployments(config);
|
|
13
10
|
deployments = data?.deployments ?? [];
|
|
14
11
|
} catch (err) {
|
|
15
|
-
console.log(chalk.yellow(
|
|
12
|
+
console.log(chalk.yellow(`\n Could not reach API: ${err.message}. Showing local state.\n`));
|
|
16
13
|
deployments = localDeployments().map(d => ({
|
|
17
14
|
deployment_id: d.id,
|
|
18
15
|
name: d.name,
|
|
19
16
|
workload_type: d.type,
|
|
20
17
|
gpu_type: d.gpu,
|
|
21
18
|
gpu_count: d.count,
|
|
22
|
-
provider: d.provider,
|
|
23
19
|
status: d.status,
|
|
24
20
|
cost_per_hour: d.costPerHour,
|
|
25
21
|
endpoint_url: d.endpointUrl,
|
|
@@ -33,7 +29,6 @@ export async function statusCommand(config, args, chalk) {
|
|
|
33
29
|
workload_type: d.type,
|
|
34
30
|
gpu_type: d.gpu,
|
|
35
31
|
gpu_count: d.count,
|
|
36
|
-
provider: d.provider,
|
|
37
32
|
status: d.status,
|
|
38
33
|
cost_per_hour: d.costPerHour,
|
|
39
34
|
endpoint_url: d.endpointUrl,
|
|
@@ -41,54 +36,46 @@ export async function statusCommand(config, args, chalk) {
|
|
|
41
36
|
}));
|
|
42
37
|
}
|
|
43
38
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
const running = deployments.filter(d => d.status === 'running' || d.status === 'provisioning');
|
|
40
|
+
|
|
41
|
+
if (running.length === 0) {
|
|
42
|
+
console.log(chalk.dim('\n Nothing running.\n'));
|
|
43
|
+
console.log(chalk.dim(' badgr run python train.py'));
|
|
44
|
+
console.log(chalk.dim(' badgr serve meta-llama/Llama-3.1-8B-Instruct\n'));
|
|
47
45
|
return;
|
|
48
46
|
}
|
|
49
47
|
|
|
50
|
-
|
|
48
|
+
console.log(chalk.bold('\nRunning now:\n'));
|
|
49
|
+
for (const d of running) {
|
|
50
|
+
const id = d.deployment_id || d.name;
|
|
51
|
+
const type = d.workload_type === 'endpoint' ? 'endpoint' : 'job';
|
|
52
|
+
const gpu = d.gpu_type || '—';
|
|
53
|
+
const rate = d.cost_per_hour > 0 ? chalk.yellow(`$${d.cost_per_hour.toFixed(2)}/hr`) : '';
|
|
54
|
+
const badge = d.status === 'provisioning'
|
|
55
|
+
? chalk.yellow('● starting')
|
|
56
|
+
: chalk.green('● running');
|
|
51
57
|
|
|
52
|
-
|
|
53
|
-
'Name'.padEnd(cols.name) +
|
|
54
|
-
'Type'.padEnd(cols.type) +
|
|
55
|
-
'GPU'.padEnd(cols.gpu) +
|
|
56
|
-
'Status'.padEnd(cols.status) +
|
|
57
|
-
'Price/hr';
|
|
58
|
-
console.log(' ' + chalk.bold(header));
|
|
59
|
-
console.log(' ' + '─'.repeat(Object.values(cols).reduce((a, b) => a + b, 0) + 8));
|
|
58
|
+
console.log(` ${badge} ${chalk.bold(id)} ${type} ${gpu} ${rate}`);
|
|
60
59
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const gpu = (d.gpu_type || '—').slice(0, cols.gpu - 1).padEnd(cols.gpu);
|
|
70
|
-
console.log(
|
|
71
|
-
' ' +
|
|
72
|
-
name.padEnd(cols.name) +
|
|
73
|
-
(d.workload_type || '—').padEnd(cols.type) +
|
|
74
|
-
gpu +
|
|
75
|
-
statusColor +
|
|
76
|
-
price
|
|
77
|
-
);
|
|
78
|
-
});
|
|
60
|
+
if (d.workload_type === 'endpoint' && d.endpoint_url) {
|
|
61
|
+
console.log(` ${chalk.dim('URL:')} ${d.endpoint_url}`);
|
|
62
|
+
}
|
|
63
|
+
if (d.model) {
|
|
64
|
+
console.log(` ${chalk.dim('Model:')} ${d.model}`);
|
|
65
|
+
}
|
|
66
|
+
console.log();
|
|
67
|
+
}
|
|
79
68
|
|
|
80
|
-
|
|
69
|
+
const billable = running.filter(d => (d.cost_per_hour || 0) > 0);
|
|
70
|
+
if (billable.length > 0) {
|
|
71
|
+
const totalPerHr = billable.reduce((s, d) => s + (d.cost_per_hour || 0), 0);
|
|
72
|
+
console.log(` ${chalk.bold('Total billing:')} ${chalk.yellow(`$${totalPerHr.toFixed(2)}/hr`)}\n`);
|
|
73
|
+
}
|
|
81
74
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
console.log(chalk.
|
|
86
|
-
endpoints.forEach(d => {
|
|
87
|
-
const url = d.endpoint_url || config.baseUrl;
|
|
88
|
-
console.log(` ${chalk.cyan(d.name || d.deployment_id)}`);
|
|
89
|
-
console.log(` ${chalk.dim('URL:')} ${url}`);
|
|
90
|
-
if (d.model) console.log(` ${chalk.dim('model:')} ${d.model}`);
|
|
91
|
-
});
|
|
92
|
-
console.log();
|
|
75
|
+
console.log(chalk.bold('Stop billing:\n'));
|
|
76
|
+
for (const d of running) {
|
|
77
|
+
const id = d.deployment_id || d.name;
|
|
78
|
+
console.log(` ${chalk.cyan(`badgr down ${id}`)}`);
|
|
93
79
|
}
|
|
80
|
+
console.log();
|
|
94
81
|
}
|