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.
@@ -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] === '--strict-capacity') { flags.noMarketplaceFallback = true; i++; continue; }
25
- if (args[i] === '--no-expanded-search') { flags.noMarketplaceFallback = true; i++; continue; }
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 (elapsedSec < 45) return 'Starting vLLM…';
51
- if (elapsedSec < 150) return 'Downloading model…';
52
- return 'Waiting for /v1/models…';
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
- async function waitForEndpoint(endpointUrl, timeoutMs = 5 * 60 * 1000, chalk) {
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}/models`, { signal: AbortSignal.timeout(8000) });
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
- // gpu=AUTO tells the backend to infer the right GPU from model size
88
- const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : 'AUTO';
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
- console.log(chalk.bold('\n⚡ Serving model\n'));
94
- console.log(` ${chalk.bold('Model:')} ${model}`);
95
- console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
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
- // Show inferred workload so the user knows what Badgr detected.
98
- if (gpu === 'AUTO') {
99
- const prof = _inferServeProfile(model);
100
- console.log();
101
- console.log(` ${chalk.bold('Estimated workload:')} ${prof.label}`);
102
- console.log(` ${chalk.bold('Estimated minimum VRAM:')} ${prof.vram}`);
103
- if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
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
- console.error(chalk.red(`\n ✗ Could not start endpoint: ${err.message}`));
150
- console.error(chalk.dim(`\n Receipt: ${failRcptId}`));
151
- console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
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
- const endpointUrl = dep.endpoint_url || dep.openai_base_url || config.baseUrl;
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
- endpointReady = await waitForEndpoint(endpointUrl, 5 * 60 * 1000, chalk);
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, 8) || 'sk-...';
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; throws or calls process.exit on failure.
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 - '/run' or '/serve'
18
- * @param {object} callOpts - { apiKey, baseUrl }
19
- * @param {function} buildBody - (tierOverride?) => body object
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 - { thing: 'job'|'endpoint', cmd: 'badgr run'|'badgr serve' }
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 handleErr(err, isFallback) {
50
+ function buildCapacityError(err, isFallback) {
34
51
  const d = err.errorData;
52
+ let msg;
35
53
  if (d?.code === 'NO_CAPACITY_MATCH') {
36
- console.error(chalk.red('\n ✗ No suitable GPU capacity available right now.\n'));
37
- console.error(chalk.dim(' Try `badgr capacity` to see what\'s available, or try again shortly.'));
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
- console.error(chalk.red(`\n ✗ No suitable capacity available right now. Try again shortly.\n`));
58
+ msg = chalk.red(`\n ✗ No suitable capacity available right now. Try again shortly.\n`);
41
59
  } else {
42
- console.error(chalk.red(`\n ✗ Capacity found but ${thing} failed to start. Please try again.\n`));
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) console.error(chalk.dim(` Detail: ${d.debug_error}`));
62
+ if (d?.debug_error) msg += chalk.dim(` Detail: ${d.debug_error}`);
45
63
  } else {
46
- console.error(chalk.dim(` Run BADGR_DEBUG=1 ${cmd} … for a full trace.\n`));
64
+ msg += chalk.dim(` Run BADGR_DEBUG=1 ${cmd} … for a full trace.\n`);
47
65
  }
48
66
  }
49
67
  } else {
50
- console.error(chalk.red(`\n ✗ Could not start ${thing}: ${err.message}\n`));
51
- console.error(chalk.dim(` Run BADGR_DEBUG=1 ${cmd} … for a full trace.`));
52
- if (!isFallback) console.error(chalk.dim(` Check config: badgr config\n`));
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
- process.exit(1);
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
- handleErr(err2, true);
85
+ throw buildCapacityError(err2, true);
68
86
  }
69
87
  }
70
88
 
71
89
  if (err.isPaymentRequired) throw err; // let caller handle payment errors
72
- handleErr(err, false);
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. Uses `diff_desc` from backend when present,
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 || '';
@@ -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 = [];