badgr-cli 1.0.30 → 1.0.32
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 +37 -18
- package/package.json +1 -1
- package/src/api.js +56 -15
- package/src/badgr.js +14 -0
- package/src/commands/run.js +151 -61
- package/src/commands/serve.js +189 -43
- package/src/fallback.js +41 -32
- package/tests/commands.test.js +52 -1
- package/tests/run-lifecycle.test.js +498 -0
- package/tests/serve-lifecycle.test.js +499 -0
package/src/commands/serve.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { requireApiKey } from '../config.js';
|
|
2
|
+
import { callApi } from '../api.js';
|
|
2
3
|
import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../store.js';
|
|
3
4
|
import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* badgr serve meta-llama/Llama-3.1-8B-Instruct
|
|
7
8
|
* badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S
|
|
9
|
+
* badgr serve BAAI/bge-large-en-v1.5 --task embed
|
|
10
|
+
* badgr serve --image ghcr.io/my-org/diffusers-api:latest --gpu L40S --env MODEL_ID=flux
|
|
8
11
|
*
|
|
9
12
|
* GPU defaults to "AUTO" — backend infers from model size.
|
|
10
13
|
*/
|
|
@@ -14,6 +17,8 @@ export function parseServeArgs(args) {
|
|
|
14
17
|
let i = 0;
|
|
15
18
|
while (i < args.length) {
|
|
16
19
|
if (args[i] === '--gpu') { flags.gpu = args[++i]; i++; continue; }
|
|
20
|
+
if (args[i] === '--image') { flags.image = args[++i]; i++; continue; }
|
|
21
|
+
if (args[i] === '--task') { flags.task = args[++i]; i++; continue; }
|
|
17
22
|
if (args[i] === '--count') { flags.count = parseInt(args[++i], 10); i++; continue; }
|
|
18
23
|
if (args[i] === '--region') { flags.region = args[++i]; i++; continue; }
|
|
19
24
|
if (args[i] === '--tier') { flags.tier = args[++i]; i++; continue; }
|
|
@@ -21,14 +26,32 @@ export function parseServeArgs(args) {
|
|
|
21
26
|
if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
|
|
22
27
|
if (args[i] === '--no-wait') { flags.noWait = true; i++; continue; }
|
|
23
28
|
if (args[i] === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
|
|
24
|
-
if (args[i] === '--
|
|
25
|
-
|
|
29
|
+
if (args[i] === '--health-path') { flags.healthPath = args[++i]; i++; continue; }
|
|
30
|
+
// All three aliases map to noMarketplaceFallback
|
|
31
|
+
if (args[i] === '--no-fallback' ||
|
|
32
|
+
args[i] === '--strict-capacity' ||
|
|
33
|
+
args[i] === '--no-expanded-search') { flags.noMarketplaceFallback = true; i++; continue; }
|
|
34
|
+
if (args[i] === '--env') {
|
|
35
|
+
const kv = args[++i]; i++;
|
|
36
|
+
if (!flags.env) flags.env = [];
|
|
37
|
+
flags.env.push(kv);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
26
40
|
positional.push(args[i++]);
|
|
27
41
|
}
|
|
28
42
|
const model = positional[0] || null;
|
|
29
43
|
return { model, flags };
|
|
30
44
|
}
|
|
31
45
|
|
|
46
|
+
function parseEnvFlag(envList) {
|
|
47
|
+
const obj = {};
|
|
48
|
+
for (const kv of (envList || [])) {
|
|
49
|
+
const idx = kv.indexOf('=');
|
|
50
|
+
if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
|
|
51
|
+
}
|
|
52
|
+
return obj;
|
|
53
|
+
}
|
|
54
|
+
|
|
32
55
|
// Mirror of backend workload_profile.py infer_profile_from_model — for pre-flight display.
|
|
33
56
|
function _inferServeProfile(modelName) {
|
|
34
57
|
const s = modelName.toLowerCase();
|
|
@@ -46,61 +69,122 @@ function _inferServeProfile(modelName) {
|
|
|
46
69
|
return { label: 'inference (70B+ model)', vram: '80+ GB', gpus: ['H100', 'A100'] };
|
|
47
70
|
}
|
|
48
71
|
|
|
49
|
-
function _serveStageLabel(elapsedSec) {
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
72
|
+
function _serveStageLabel(elapsedSec, healthPath = '/models') {
|
|
73
|
+
if (healthPath === '/models') {
|
|
74
|
+
if (elapsedSec < 45) return 'Starting vLLM…';
|
|
75
|
+
if (elapsedSec < 150) return 'Downloading model…';
|
|
76
|
+
return 'Waiting for /v1/models…';
|
|
77
|
+
}
|
|
78
|
+
if (elapsedSec < 30) return 'Starting container…';
|
|
79
|
+
if (elapsedSec < 120) return 'Container starting…';
|
|
80
|
+
return `Waiting for ${healthPath}…`;
|
|
53
81
|
}
|
|
54
82
|
|
|
55
|
-
|
|
83
|
+
function _detectHealthPath(image) {
|
|
84
|
+
if (!image) return null;
|
|
85
|
+
return image.toLowerCase().includes('comfyui') ? '/system_stats' : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Poll the model endpoint until /models returns 200, or timeout.
|
|
90
|
+
* Also checks deployment status on each iteration — fails fast if the dep is dead.
|
|
91
|
+
* Returns { ready: boolean, timedOut: boolean, depFailed: boolean, failReason?: string }
|
|
92
|
+
*/
|
|
93
|
+
async function waitForEndpoint(endpointUrl, deploymentId, config, timeoutMs = 5 * 60 * 1000, chalk, healthPath = '/models') {
|
|
56
94
|
const startMs = Date.now();
|
|
57
95
|
const deadline = startMs + timeoutMs;
|
|
58
96
|
|
|
59
97
|
while (Date.now() < deadline) {
|
|
98
|
+
// Check deployment status first — fail fast on OOM/crash before waiting more
|
|
99
|
+
try {
|
|
100
|
+
const dep = await callApi(`/deployments/${deploymentId}`, {
|
|
101
|
+
apiKey: config.apiKey,
|
|
102
|
+
baseUrl: config.baseUrl,
|
|
103
|
+
timeoutMs: 10_000,
|
|
104
|
+
});
|
|
105
|
+
if (['failed', 'terminated', 'error', 'stopped'].includes(dep.status)) {
|
|
106
|
+
process.stdout.write('\n');
|
|
107
|
+
return { ready: false, timedOut: false, depFailed: true, failReason: dep.error || dep.status };
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// status check failed — continue to endpoint check
|
|
111
|
+
}
|
|
112
|
+
|
|
60
113
|
try {
|
|
61
|
-
const res = await fetch(`${endpointUrl}
|
|
62
|
-
if (res.ok) { process.stdout.write('\n'); return true; }
|
|
114
|
+
const res = await fetch(`${endpointUrl}${healthPath}`, { signal: AbortSignal.timeout(8000) });
|
|
115
|
+
if (res.ok) { process.stdout.write('\n'); return { ready: true, timedOut: false, depFailed: false }; }
|
|
63
116
|
} catch {
|
|
64
117
|
// still starting
|
|
65
118
|
}
|
|
119
|
+
|
|
66
120
|
const elapsed = Math.round((Date.now() - startMs) / 1000);
|
|
67
121
|
process.stdout.write(
|
|
68
|
-
`\r ${chalk.dim(_serveStageLabel(elapsed) + ` (${elapsed}s)`)} `
|
|
122
|
+
`\r ${chalk.dim(_serveStageLabel(elapsed, healthPath) + ` (${elapsed}s)`)} `
|
|
69
123
|
);
|
|
70
124
|
await new Promise(r => setTimeout(r, 8000));
|
|
71
125
|
}
|
|
126
|
+
|
|
72
127
|
process.stdout.write('\n');
|
|
73
|
-
return false;
|
|
128
|
+
return { ready: false, timedOut: true, depFailed: false };
|
|
74
129
|
}
|
|
75
130
|
|
|
76
131
|
export async function serveCommand(config, args, chalk) {
|
|
77
132
|
const { model, flags } = parseServeArgs(args);
|
|
133
|
+
const customImage = flags.image || null;
|
|
78
134
|
|
|
79
|
-
if (!model) {
|
|
135
|
+
if (!model && !customImage) {
|
|
80
136
|
console.error(chalk.red('Usage: badgr serve <model>'));
|
|
81
137
|
console.error(chalk.red(' badgr serve meta-llama/Llama-3.1-8B-Instruct'));
|
|
138
|
+
console.error(chalk.red(' badgr serve --image ghcr.io/my-org/api:latest --gpu L40S'));
|
|
82
139
|
return;
|
|
83
140
|
}
|
|
84
141
|
|
|
85
142
|
requireApiKey(config);
|
|
86
143
|
|
|
87
|
-
//
|
|
88
|
-
|
|
144
|
+
// ── Validate flags early ───────────────────────────────────────────────────
|
|
145
|
+
if (flags.count !== undefined && (!Number.isFinite(flags.count) || flags.count < 1)) {
|
|
146
|
+
console.error(chalk.red(' ✗ --count must be an integer greater than 0'));
|
|
147
|
+
process.exitCode = 1;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (flags.maxCost !== undefined && (!Number.isFinite(flags.maxCost) || flags.maxCost <= 0)) {
|
|
151
|
+
console.error(chalk.red(' ✗ --max-cost must be a number greater than 0'));
|
|
152
|
+
process.exitCode = 1;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (flags.region !== undefined && !['US', 'EU', 'AU'].includes(flags.region.toUpperCase())) {
|
|
156
|
+
console.error(chalk.red(' ✗ --region must be US, EU, or AU'));
|
|
157
|
+
process.exitCode = 1;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const envObj = parseEnvFlag(flags.env);
|
|
162
|
+
|
|
163
|
+
const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : (customImage ? 'L40S' : 'AUTO');
|
|
89
164
|
const gpuLabel = gpu === 'AUTO' ? 'auto' : gpu;
|
|
90
165
|
|
|
91
166
|
const effectiveTier = normalizeTier(flags.tier);
|
|
92
167
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
168
|
+
if (customImage) {
|
|
169
|
+
console.log(chalk.bold('\n⚡ Serving custom container\n'));
|
|
170
|
+
console.log(` ${chalk.bold('Image:')} ${customImage}`);
|
|
171
|
+
console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
|
|
172
|
+
if (flags.task) console.log(` ${chalk.bold('Task:')} ${flags.task}`);
|
|
173
|
+
if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
|
|
174
|
+
} else {
|
|
175
|
+
console.log(chalk.bold('\n⚡ Serving model\n'));
|
|
176
|
+
console.log(` ${chalk.bold('Model:')} ${model}`);
|
|
177
|
+
console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
|
|
178
|
+
if (flags.task) console.log(` ${chalk.bold('Task:')} ${flags.task}`);
|
|
179
|
+
if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
|
|
96
180
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
181
|
+
if (gpu === 'AUTO') {
|
|
182
|
+
const prof = _inferServeProfile(model);
|
|
183
|
+
console.log();
|
|
184
|
+
console.log(` ${chalk.bold('Estimated workload:')} ${prof.label}`);
|
|
185
|
+
console.log(` ${chalk.bold('Estimated minimum VRAM:')} ${prof.vram}`);
|
|
186
|
+
if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
|
|
187
|
+
}
|
|
104
188
|
}
|
|
105
189
|
console.log();
|
|
106
190
|
process.stdout.write(chalk.dim(' Finding suitable capacity...\n'));
|
|
@@ -109,13 +193,16 @@ export async function serveCommand(config, args, chalk) {
|
|
|
109
193
|
|
|
110
194
|
function buildBody(gpuOverride, tierOverride) {
|
|
111
195
|
return {
|
|
112
|
-
model,
|
|
196
|
+
...(model ? { model } : {}),
|
|
197
|
+
...(customImage ? { image: customImage } : {}),
|
|
198
|
+
...(flags.task ? { task: flags.task } : {}),
|
|
113
199
|
gpu: gpuOverride || gpu,
|
|
114
200
|
gpu_count: flags.count || 1,
|
|
115
201
|
...(effectiveRegion ? { region: effectiveRegion } : {}),
|
|
116
202
|
max_price_per_hour: flags.maxPrice,
|
|
117
203
|
name: flags.name,
|
|
118
204
|
tier: tierOverride || effectiveTier,
|
|
205
|
+
...(Object.keys(envObj).length > 0 ? { env: envObj } : {}),
|
|
119
206
|
};
|
|
120
207
|
}
|
|
121
208
|
|
|
@@ -128,27 +215,19 @@ export async function serveCommand(config, args, chalk) {
|
|
|
128
215
|
effectiveTier,
|
|
129
216
|
chalk,
|
|
130
217
|
{ thing: 'endpoint', cmd: 'badgr serve' },
|
|
218
|
+
{ allowTier2Fallback: !flags.noMarketplaceFallback },
|
|
131
219
|
);
|
|
132
220
|
} catch (err) {
|
|
133
|
-
const failRcptId = generateReceiptId();
|
|
134
|
-
addReceipt({
|
|
135
|
-
receiptId: failRcptId,
|
|
136
|
-
action: 'badgr serve',
|
|
137
|
-
model,
|
|
138
|
-
gpu: gpuLabel,
|
|
139
|
-
status: 'failed',
|
|
140
|
-
failureType: 'infrastructure',
|
|
141
|
-
createdAt: new Date().toISOString(),
|
|
142
|
-
});
|
|
143
221
|
if (err.isPaymentRequired) {
|
|
144
222
|
console.error(chalk.yellow(err.message));
|
|
145
223
|
const rerun = `badgr serve ${args.join(' ')}`;
|
|
146
224
|
console.error(chalk.dim(`After payment, rerun:\n ${rerun}\n`));
|
|
225
|
+
process.exitCode = 1;
|
|
147
226
|
return;
|
|
148
227
|
}
|
|
149
|
-
|
|
150
|
-
console.error(
|
|
151
|
-
|
|
228
|
+
// CapacityError message is pre-formatted with chalk
|
|
229
|
+
console.error(err.message);
|
|
230
|
+
process.exitCode = 1;
|
|
152
231
|
return;
|
|
153
232
|
}
|
|
154
233
|
|
|
@@ -180,18 +259,84 @@ export async function serveCommand(config, args, chalk) {
|
|
|
180
259
|
createdAt: new Date().toISOString(),
|
|
181
260
|
});
|
|
182
261
|
|
|
183
|
-
|
|
262
|
+
// ── Fix 7: never fall back to config.baseUrl for endpoint health check ─────
|
|
263
|
+
const endpointUrl = dep.endpoint_url || dep.openai_base_url;
|
|
264
|
+
if (!endpointUrl) {
|
|
265
|
+
console.error(chalk.red(
|
|
266
|
+
`\n ✗ Deployment ${dep.deployment_id} has no endpoint URL yet.\n` +
|
|
267
|
+
` Run: badgr status ${dep.deployment_id}\n`
|
|
268
|
+
));
|
|
269
|
+
updateReceipt(rcptId, { status: 'no_endpoint_url' });
|
|
270
|
+
process.exitCode = 1;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── Determine health check path ───────────────────────────────────────────
|
|
275
|
+
// Priority: explicit --health-path > auto-detect comfyui > /models for vLLM > null (skip)
|
|
276
|
+
let resolvedHealthPath;
|
|
277
|
+
if (flags.healthPath) {
|
|
278
|
+
resolvedHealthPath = flags.healthPath;
|
|
279
|
+
} else if (!customImage) {
|
|
280
|
+
resolvedHealthPath = '/models';
|
|
281
|
+
} else {
|
|
282
|
+
resolvedHealthPath = _detectHealthPath(customImage); // '/system_stats' for comfyui, null otherwise
|
|
283
|
+
}
|
|
184
284
|
|
|
185
285
|
// ── Health check ──────────────────────────────────────────────────────────
|
|
186
286
|
let endpointReady = false;
|
|
187
287
|
if (flags.noWait) {
|
|
188
288
|
console.log(chalk.yellow('\n Skipped health check (--no-wait)\n'));
|
|
289
|
+
} else if (resolvedHealthPath === null) {
|
|
290
|
+
console.log(chalk.yellow(
|
|
291
|
+
'\n Skipping health check (custom image — add --health-path /your-readiness-path to enable)\n'
|
|
292
|
+
));
|
|
189
293
|
} else {
|
|
190
|
-
|
|
294
|
+
if (resolvedHealthPath !== '/models') {
|
|
295
|
+
process.stdout.write(chalk.dim(` Checking ${resolvedHealthPath} for readiness…\n`));
|
|
296
|
+
}
|
|
297
|
+
// Check deployment status once before starting the 5-min wait
|
|
298
|
+
try {
|
|
299
|
+
const latest = await callApi(`/deployments/${dep.deployment_id}`, {
|
|
300
|
+
apiKey: config.apiKey,
|
|
301
|
+
baseUrl: config.baseUrl,
|
|
302
|
+
timeoutMs: 10_000,
|
|
303
|
+
});
|
|
304
|
+
if (['failed', 'terminated', 'error'].includes(latest.status)) {
|
|
305
|
+
console.error(chalk.red(
|
|
306
|
+
`\n ✗ Deployment failed before endpoint became ready: ${latest.error || latest.status}\n`
|
|
307
|
+
));
|
|
308
|
+
updateReceipt(rcptId, { status: 'failed', failReason: latest.error || latest.status });
|
|
309
|
+
process.exitCode = 1;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
} catch {
|
|
313
|
+
// status check failed — proceed with endpoint poll anyway
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const healthResult = await waitForEndpoint(endpointUrl, dep.deployment_id, config, 5 * 60 * 1000, chalk, resolvedHealthPath);
|
|
191
317
|
process.stdout.write('\n');
|
|
318
|
+
|
|
319
|
+
if (healthResult.depFailed) {
|
|
320
|
+
console.error(chalk.red(
|
|
321
|
+
`\n ✗ Deployment failed during startup: ${healthResult.failReason || 'unknown error'}\n` +
|
|
322
|
+
` Check logs: badgr logs ${dep.deployment_id}\n`
|
|
323
|
+
));
|
|
324
|
+
updateReceipt(rcptId, { status: 'failed', failReason: healthResult.failReason });
|
|
325
|
+
process.exitCode = 1;
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
endpointReady = healthResult.ready;
|
|
192
330
|
if (!endpointReady) updateReceipt(rcptId, { status: 'health_check_timeout' });
|
|
193
331
|
}
|
|
194
332
|
|
|
333
|
+
// ── Update receipt with final state ───────────────────────────────────────
|
|
334
|
+
updateReceipt(rcptId, {
|
|
335
|
+
status: endpointReady ? 'ready' : 'starting',
|
|
336
|
+
endpointUrl,
|
|
337
|
+
updatedAt: new Date().toISOString(),
|
|
338
|
+
});
|
|
339
|
+
|
|
195
340
|
// ── Result ────────────────────────────────────────────────────────────────
|
|
196
341
|
if (endpointReady) {
|
|
197
342
|
console.log(chalk.green('\n ✓ Endpoint ready\n'));
|
|
@@ -205,7 +350,8 @@ export async function serveCommand(config, args, chalk) {
|
|
|
205
350
|
const serveRate = dep.cost_per_hour || 0;
|
|
206
351
|
|
|
207
352
|
console.log(` ${chalk.bold('Base URL:')} ${chalk.cyan(endpointUrl)}`);
|
|
208
|
-
console.log(` ${chalk.bold('Model:')} ${dep.model || model}`);
|
|
353
|
+
if (dep.model || model) console.log(` ${chalk.bold('Model:')} ${dep.model || model}`);
|
|
354
|
+
if (customImage) console.log(` ${chalk.bold('Image:')} ${customImage}`);
|
|
209
355
|
console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
|
|
210
356
|
if (serveRate > 0) console.log(` ${chalk.bold('Rate:')} $${serveRate.toFixed(2)}/hr`);
|
|
211
357
|
if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
|
|
@@ -220,8 +366,8 @@ export async function serveCommand(config, args, chalk) {
|
|
|
220
366
|
}
|
|
221
367
|
console.log(chalk.dim(' Billing continues until you run: ') + chalk.cyan(`badgr down ${dep.deployment_id}`));
|
|
222
368
|
|
|
223
|
-
if (endpointReady) {
|
|
224
|
-
const keySnip = config.apiKey?.slice(0,
|
|
369
|
+
if (endpointReady && !customImage) {
|
|
370
|
+
const keySnip = config.apiKey?.slice(0, 4) || 'sk-...';
|
|
225
371
|
console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
|
|
226
372
|
console.log(chalk.dim(` from openai import OpenAI`));
|
|
227
373
|
console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${keySnip}...")`));
|
package/src/fallback.js
CHANGED
|
@@ -10,48 +10,66 @@ export function normalizeTier(tier) {
|
|
|
10
10
|
return (tier === '2' || tier === 'tier2' || tier === 'tier-2') ? '2' : (tier || '1');
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Sentinel error thrown when callWithFallback cannot provision capacity.
|
|
15
|
+
* The message is already formatted for display; callers should print it and exit.
|
|
16
|
+
*/
|
|
17
|
+
export class CapacityError extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'CapacityError';
|
|
21
|
+
this.isCapacityError = true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
13
25
|
/**
|
|
14
26
|
* Call an API endpoint with automatic tier-2 expansion on NO_CAPACITY_MATCH.
|
|
15
|
-
* Returns the deployment object on success
|
|
27
|
+
* Returns the deployment object on success.
|
|
28
|
+
* Throws CapacityError (pre-formatted for display) on unrecoverable failure.
|
|
29
|
+
* Re-throws payment errors (err.isPaymentRequired) for callers to handle.
|
|
16
30
|
*
|
|
17
|
-
* @param {string} endpoint
|
|
18
|
-
* @param {object} callOpts
|
|
19
|
-
* @param {function} buildBody
|
|
31
|
+
* @param {string} endpoint - '/run' or '/serve'
|
|
32
|
+
* @param {object} callOpts - { apiKey, baseUrl }
|
|
33
|
+
* @param {function} buildBody - (tierOverride?) => body object
|
|
20
34
|
* @param {string} effectiveTier
|
|
21
35
|
* @param {object} chalk
|
|
22
|
-
* @param {object} labels
|
|
36
|
+
* @param {object} labels - { thing: 'job'|'endpoint', cmd: 'badgr run'|'badgr serve' }
|
|
37
|
+
* @param {object} [opts]
|
|
38
|
+
* @param {boolean} [opts.allowTier2Fallback=true] - set false to disable tier-2 expansion
|
|
23
39
|
*/
|
|
24
|
-
export async function callWithFallback(endpoint, callOpts, buildBody, effectiveTier, chalk, labels) {
|
|
40
|
+
export async function callWithFallback(endpoint, callOpts, buildBody, effectiveTier, chalk, labels, opts = {}) {
|
|
25
41
|
const { callApi } = await import('./api.js');
|
|
26
42
|
const thing = labels?.thing ?? 'job';
|
|
27
43
|
const cmd = labels?.cmd ?? 'badgr run';
|
|
44
|
+
const allowTier2Fallback = opts.allowTier2Fallback !== false; // default true
|
|
28
45
|
|
|
29
46
|
async function attempt(body) {
|
|
30
|
-
return callApi(endpoint, { method: 'POST', ...callOpts, body });
|
|
47
|
+
return callApi(endpoint, { method: 'POST', ...callOpts, body, timeoutMs: 30_000 });
|
|
31
48
|
}
|
|
32
49
|
|
|
33
|
-
function
|
|
50
|
+
function buildCapacityError(err, isFallback) {
|
|
34
51
|
const d = err.errorData;
|
|
52
|
+
let msg;
|
|
35
53
|
if (d?.code === 'NO_CAPACITY_MATCH') {
|
|
36
|
-
|
|
37
|
-
|
|
54
|
+
msg = chalk.red('\n ✗ No suitable GPU capacity available right now.\n') +
|
|
55
|
+
chalk.dim(' Try `badgr capacity` to see what\'s available, or try again shortly.');
|
|
38
56
|
} else if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
|
|
39
57
|
if (d?.low_cost_provider_failed) {
|
|
40
|
-
|
|
58
|
+
msg = chalk.red(`\n ✗ No suitable capacity available right now. Try again shortly.\n`);
|
|
41
59
|
} else {
|
|
42
|
-
|
|
60
|
+
msg = chalk.red(`\n ✗ Capacity found but ${thing} failed to start. Please try again.\n`);
|
|
43
61
|
if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
|
|
44
|
-
if (d?.debug_error)
|
|
62
|
+
if (d?.debug_error) msg += chalk.dim(` Detail: ${d.debug_error}`);
|
|
45
63
|
} else {
|
|
46
|
-
|
|
64
|
+
msg += chalk.dim(` Run BADGR_DEBUG=1 ${cmd} … for a full trace.\n`);
|
|
47
65
|
}
|
|
48
66
|
}
|
|
49
67
|
} else {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (!isFallback)
|
|
68
|
+
msg = chalk.red(`\n ✗ Could not start ${thing}: ${err.message}\n`) +
|
|
69
|
+
chalk.dim(` Run BADGR_DEBUG=1 ${cmd} … for a full trace.`);
|
|
70
|
+
if (!isFallback) msg += '\n' + chalk.dim(` Check config: badgr config\n`);
|
|
53
71
|
}
|
|
54
|
-
|
|
72
|
+
return new CapacityError(msg);
|
|
55
73
|
}
|
|
56
74
|
|
|
57
75
|
try {
|
|
@@ -59,26 +77,26 @@ export async function callWithFallback(endpoint, callOpts, buildBody, effectiveT
|
|
|
59
77
|
} catch (err) {
|
|
60
78
|
const d = err.errorData;
|
|
61
79
|
|
|
62
|
-
if (d?.code === 'NO_CAPACITY_MATCH' && effectiveTier !== '2') {
|
|
80
|
+
if (d?.code === 'NO_CAPACITY_MATCH' && effectiveTier !== '2' && allowTier2Fallback) {
|
|
63
81
|
console.log(chalk.dim('\n Primary capacity unavailable — expanding search...\n'));
|
|
64
82
|
try {
|
|
65
83
|
return await attempt(buildBody('2'));
|
|
66
84
|
} catch (err2) {
|
|
67
|
-
|
|
85
|
+
throw buildCapacityError(err2, true);
|
|
68
86
|
}
|
|
69
87
|
}
|
|
70
88
|
|
|
71
89
|
if (err.isPaymentRequired) throw err; // let caller handle payment errors
|
|
72
|
-
|
|
90
|
+
throw buildCapacityError(err, false);
|
|
73
91
|
}
|
|
74
92
|
}
|
|
75
93
|
|
|
76
94
|
// ── GPU fallback prompt ───────────────────────────────────────────────────────
|
|
77
95
|
|
|
78
96
|
// GPU descriptions for display only — no scoring logic lives here.
|
|
79
|
-
// Ranking is computed server-side and returned as `rank` on each alternative.
|
|
80
97
|
const GPU_DISPLAY = {
|
|
81
98
|
RTX_3080: { desc: 'Dev, light inference' },
|
|
99
|
+
RTX_3090: { desc: 'Dev, inference' },
|
|
82
100
|
RTX_4080: { desc: 'Inference and dev workloads' },
|
|
83
101
|
RTX_4090: { desc: 'Inference, training, dev' },
|
|
84
102
|
L40S: { desc: 'Inference, vLLM, batch jobs' },
|
|
@@ -89,27 +107,18 @@ const GPU_DISPLAY = {
|
|
|
89
107
|
|
|
90
108
|
/**
|
|
91
109
|
* Sort alternatives for display.
|
|
92
|
-
*
|
|
93
|
-
* - mode 'closest' → use backend `rank` field (1 = best match); fall back to price.
|
|
94
|
-
* - mode 'cheapest' → sort by price ascending only.
|
|
95
|
-
*
|
|
96
|
-
* The scoring algorithm has been removed from the CLI; the backend now
|
|
97
|
-
* computes and attaches `rank` and `diff_desc` to every alternative.
|
|
98
110
|
*/
|
|
99
111
|
export function rankAlternatives(requestedGpu, alternatives, mode = 'closest') {
|
|
100
112
|
if (!alternatives || alternatives.length === 0) return [];
|
|
101
113
|
if (mode === 'cheapest') return [...alternatives].sort((a, b) => a.price - b.price);
|
|
102
|
-
// 'closest': honour backend rank when present
|
|
103
114
|
if (alternatives.some(a => a.rank != null)) {
|
|
104
115
|
return [...alternatives].sort((a, b) => (a.rank ?? 999) - (b.rank ?? 999));
|
|
105
116
|
}
|
|
106
|
-
// Older backend without rank — fall back to price
|
|
107
117
|
return [...alternatives].sort((a, b) => a.price - b.price);
|
|
108
118
|
}
|
|
109
119
|
|
|
110
120
|
/**
|
|
111
|
-
* One-line diff for display.
|
|
112
|
-
* otherwise omits the diff rather than duplicating the scoring logic.
|
|
121
|
+
* One-line diff for display.
|
|
113
122
|
*/
|
|
114
123
|
export function diffDescription(requestedGpu, altGpu, alt = {}) {
|
|
115
124
|
return alt.diff_desc || '';
|
package/tests/commands.test.js
CHANGED
|
@@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
|
|
|
2
2
|
import { parseRunArgs, classifyFailure } from '../src/commands/run.js';
|
|
3
3
|
import { parseServeArgs } from '../src/commands/serve.js';
|
|
4
4
|
import { testCommand, parseTestArgs } from '../src/commands/test-run.js';
|
|
5
|
-
import { rankAlternatives, diffDescription, promptFallback } from '../src/fallback.js';
|
|
5
|
+
import { rankAlternatives, diffDescription, promptFallback, CapacityError } from '../src/fallback.js';
|
|
6
6
|
|
|
7
7
|
describe('parseRunArgs', () => {
|
|
8
8
|
it('parses a plain command', () => {
|
|
@@ -208,6 +208,47 @@ describe('parseServeArgs', () => {
|
|
|
208
208
|
const { flags } = parseServeArgs(['my/model']);
|
|
209
209
|
expect(flags.tier).toBeUndefined();
|
|
210
210
|
});
|
|
211
|
+
|
|
212
|
+
it('parses --no-fallback as noMarketplaceFallback', () => {
|
|
213
|
+
const { flags } = parseServeArgs(['my/model', '--no-fallback']);
|
|
214
|
+
expect(flags.noMarketplaceFallback).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('parses --strict-capacity as noMarketplaceFallback', () => {
|
|
218
|
+
const { flags } = parseServeArgs(['my/model', '--strict-capacity']);
|
|
219
|
+
expect(flags.noMarketplaceFallback).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('parses --no-expanded-search as noMarketplaceFallback', () => {
|
|
223
|
+
const { flags } = parseServeArgs(['my/model', '--no-expanded-search']);
|
|
224
|
+
expect(flags.noMarketplaceFallback).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('noMarketplaceFallback defaults to falsy when not passed', () => {
|
|
228
|
+
const { flags } = parseServeArgs(['my/model']);
|
|
229
|
+
expect(flags.noMarketplaceFallback).toBeFalsy();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('model is not consumed by --no-fallback flag', () => {
|
|
233
|
+
const { model, flags } = parseServeArgs(['my/model', '--no-fallback']);
|
|
234
|
+
expect(model).toBe('my/model');
|
|
235
|
+
expect(flags.noMarketplaceFallback).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('parses --health-path flag', () => {
|
|
239
|
+
const { flags } = parseServeArgs(['my/model', '--health-path', '/system_stats']);
|
|
240
|
+
expect(flags.healthPath).toBe('/system_stats');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('parses --health-path with arbitrary path', () => {
|
|
244
|
+
const { flags } = parseServeArgs(['--image', 'my/image:latest', '--health-path', '/health']);
|
|
245
|
+
expect(flags.healthPath).toBe('/health');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('healthPath defaults to undefined when not passed', () => {
|
|
249
|
+
const { flags } = parseServeArgs(['my/model']);
|
|
250
|
+
expect(flags.healthPath).toBeUndefined();
|
|
251
|
+
});
|
|
211
252
|
});
|
|
212
253
|
|
|
213
254
|
describe('testCommand', () => {
|
|
@@ -238,6 +279,16 @@ describe('parseTestArgs', () => {
|
|
|
238
279
|
});
|
|
239
280
|
});
|
|
240
281
|
|
|
282
|
+
describe('CapacityError', () => {
|
|
283
|
+
it('is an Error subclass', () => {
|
|
284
|
+
const err = new CapacityError('no capacity');
|
|
285
|
+
expect(err).toBeInstanceOf(Error);
|
|
286
|
+
expect(err.isCapacityError).toBe(true);
|
|
287
|
+
expect(err.name).toBe('CapacityError');
|
|
288
|
+
expect(err.message).toBe('no capacity');
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
241
292
|
describe('promptFallback output', () => {
|
|
242
293
|
it('does not print a Difference line', async () => {
|
|
243
294
|
const lines = [];
|