badgr-cli 1.0.8 → 1.0.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "badgr-cli",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "Badgr — run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
package/src/badgr.js CHANGED
@@ -26,12 +26,17 @@ ${chalk.bold('CORE COMMANDS')}
26
26
  ${chalk.cyan('badgr capacity [--gpu <type>]')} Check live GPU availability and alternatives
27
27
 
28
28
  ${chalk.bold('badgr run OPTIONS')}
29
- --gpu <type> GPU: RTX_4090, A100, L40S, H100 (default: RTX_4090)
30
- --image <image> Docker image (default: python:3.11-slim)
31
- --count <n> GPU count (default: 1)
32
- --region US|EU|AU Region preference (default: US)
33
- --max-price <$/hr> Hard spend cap per GPU-hour
34
- --name <name> Job name (auto-generated if omitted)
29
+ --gpu <type> GPU: RTX_4090, A100, L40S, H100 (default: RTX_4090)
30
+ --image <image> Docker image (default: python:3.11-slim)
31
+ --count <n> GPU count (default: 1)
32
+ --region US|EU|AU Region preference (default: US)
33
+ --max-price <$/hr> Hard spend cap per GPU-hour
34
+ --name <name> Job name (auto-generated if omitted)
35
+ --fallback closest|cheapest If requested GPU is unavailable, interactively pick the
36
+ closest (default) or cheapest alternative
37
+ --no-fallback Exit immediately when requested GPU is unavailable
38
+ --max-runtime <minutes> Auto-teardown job after N minutes (prevents runaway billing)
39
+ --max-cost <$> Auto-teardown when total spend reaches this amount
35
40
 
36
41
  ${chalk.bold('badgr serve OPTIONS')}
37
42
  --gpu <type> GPU: L40S, A100, H100, RTX_4090 (default: L40S)
@@ -43,6 +48,9 @@ ${chalk.bold('badgr serve OPTIONS')}
43
48
  ${chalk.bold('EXAMPLES')}
44
49
  badgr login
45
50
  badgr run python train.py --gpu A100
51
+ badgr run python train.py --gpu A100 --fallback closest
52
+ badgr run python train.py --gpu A100 --fallback cheapest
53
+ badgr run python train.py --gpu A100 --no-fallback
46
54
  badgr run --image my/image:latest --gpu L40S
47
55
  badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S
48
56
  badgr serve mistralai/Mistral-7B-v0.1 --gpu RTX_4090
@@ -5,19 +5,34 @@ function fmtMs(ms) {
5
5
  return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
6
6
  }
7
7
 
8
+ function fmtRuntime(s) {
9
+ if (s < 60) return `${s}s`;
10
+ return `${Math.floor(s / 60)}m ${s % 60}s`;
11
+ }
12
+
8
13
  function printReceipt(r, chalk) {
9
14
  const id = r.receiptId ?? r.request_id ?? r.id ?? '—';
10
15
  const ts = r.createdAt ?? r.created_at ?? '—';
11
- const cost = r.route?.ratePerHour ?? r.cost_usd ?? 0;
12
- const prov = r.route?.provider ?? r.provider ?? r.model_provider ?? '—';
13
- const lat = r.latencyMs ?? r.latency_ms;
14
16
  const action = r.action ?? r.endpoint ?? '—';
17
+ const lat = r.latencyMs ?? r.latency_ms;
15
18
 
16
19
  console.log(` ${chalk.cyan(id)}`);
17
20
  console.log(` ${chalk.bold('action:')} ${action}`);
21
+
22
+ // GPU job fields (from badgr run / badgr down)
23
+ if (r.gpu) console.log(` ${chalk.bold('gpu:')} ${r.gpu}`);
24
+ if (r.runtimeSeconds) console.log(` ${chalk.bold('runtime:')} ${fmtRuntime(r.runtimeSeconds)}`);
25
+ if (r.finalCost) console.log(` ${chalk.bold('cost:')} $${r.finalCost.toFixed(4)}`);
26
+ else if (r.cost_usd) console.log(` ${chalk.bold('cost:')} $${r.cost_usd.toFixed(4)}`);
27
+ if (r.exitCode !== undefined && r.exitCode !== null) {
28
+ console.log(` ${chalk.bold('exit:')} ${r.exitCode !== 0 ? chalk.red(r.exitCode) : chalk.green(r.exitCode)}`);
29
+ }
30
+
31
+ // API inference receipt fields
18
32
  if (lat !== undefined) console.log(` ${chalk.bold('latency:')} ${fmtMs(lat)}`);
19
- if (cost) console.log(` ${chalk.bold('cost:')} $${typeof cost === 'number' ? cost.toFixed(4) : cost}/hr`);
20
33
  if (r.retries !== undefined) console.log(` ${chalk.bold('retries:')} ${r.retries}`);
34
+ if (r.route?.ratePerHour) console.log(` ${chalk.bold('rate:')} $${r.route.ratePerHour.toFixed(4)}/hr`);
35
+
21
36
  if (r.status) console.log(` ${chalk.bold('status:')} ${r.status}`);
22
37
  console.log(` ${chalk.dim(ts)}`);
23
38
  console.log();
@@ -1,6 +1,7 @@
1
1
  import { requireApiKey } from '../config.js';
2
- import { callApi } from '../api.js';
3
- import { addReceipt, generateReceiptId } from '../store.js';
2
+ import { callApi, terminateDeployment } from '../api.js';
3
+ import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
4
+ import { rankAlternatives, promptFallback } from '../fallback.js';
4
5
 
5
6
  /**
6
7
  * badgr run python train.py --gpu A100 # attached (default)
@@ -20,60 +21,137 @@ export function parseRunArgs(args) {
20
21
  if (args[i] === '--region') { flags.region = args[++i]; i++; continue; }
21
22
  if (args[i] === '--max-price') { flags.maxPrice = parseFloat(args[++i]); i++; continue; }
22
23
  if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
23
- if (args[i] === '--detach') { flags.detach = true; i++; continue; }
24
+ if (args[i] === '--detach') { flags.detach = true; i++; continue; }
25
+ if (args[i] === '--fallback') { flags.fallback = args[++i]; i++; continue; }
26
+ if (args[i] === '--no-fallback') { flags.noFallback = true; i++; continue; }
27
+ if (args[i] === '--max-runtime') { flags.maxRuntime = parseFloat(args[++i]); i++; continue; }
28
+ if (args[i] === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
24
29
  positional.push(args[i++]);
25
30
  }
26
31
  return { flags, positional };
27
32
  }
28
33
 
29
- // Poll /v1/deployments/:id until terminal status, streaming new log lines.
30
- async function attachToJob(config, depId, chalk) {
34
+ function fmtRuntime(ms) {
35
+ const s = Math.round(ms / 1000);
36
+ if (s < 60) return `${s}s`;
37
+ const m = Math.floor(s / 60);
38
+ return `${m}m ${s % 60}s`;
39
+ }
40
+
41
+ // Polls above this count without a response while 'running' → heartbeat lost.
42
+ const HEARTBEAT_WARN_POLLS = 3; // ~12s → warn
43
+ const HEARTBEAT_KILL_POLLS = 15; // ~60s → treat as infrastructure failure
44
+
45
+ /**
46
+ * Classify a failure into 'customer_code' or 'infrastructure'.
47
+ * customer_code: process ran and exited non-zero.
48
+ * infrastructure: provider issue — job never completed normally.
49
+ */
50
+ export function classifyFailure(finalStatus, exitCode) {
51
+ if (finalStatus === 'failed' && (exitCode === null || exitCode === undefined)) return 'infrastructure';
52
+ if (exitCode !== null && exitCode !== undefined && exitCode !== 0) return 'customer_code';
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Poll until terminal status, streaming new log lines.
58
+ * Returns { status, exitCode, runtimeMs, failureType }.
59
+ * Calls onTeardown(reason) when SIGINT, maxRuntime, maxCost, or heartbeat loss hits.
60
+ */
61
+ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost = null, ratePerHour = 0, onTeardown }) {
31
62
  const TERMINAL = new Set(['stopped', 'failed', 'completed']);
32
63
  const POLL_MS = 4000;
33
- let seenLines = 0;
34
- let lastStatus = '';
64
+ let seenLines = 0;
65
+ let lastStatus = '';
66
+ let consecutiveErrs = 0;
67
+ const startMs = Date.now();
35
68
 
36
- while (true) {
37
- await new Promise(r => setTimeout(r, POLL_MS));
69
+ let tearing = false;
70
+ const sigintHandler = () => {
71
+ if (tearing) return;
72
+ tearing = true;
73
+ onTeardown('interrupted');
74
+ };
75
+ process.once('SIGINT', sigintHandler);
38
76
 
39
- // Fetch status
40
- let dep;
41
- try {
42
- dep = await callApi(`/deployments/${depId}`, {
43
- apiKey: config.apiKey,
44
- baseUrl: config.baseUrl,
45
- });
46
- } catch {
47
- // transient network error — keep trying
48
- continue;
49
- }
77
+ try {
78
+ while (true) {
79
+ await new Promise(r => setTimeout(r, POLL_MS));
80
+
81
+ const elapsedMs = Date.now() - startMs;
82
+ const spentSoFar = ratePerHour * (elapsedMs / 3_600_000);
50
83
 
51
- const status = dep.status;
52
- if (status !== lastStatus) {
53
- if (status === 'running' && lastStatus === 'provisioning') {
54
- console.log(chalk.dim(' [running]'));
84
+ // Hard spend cap
85
+ if (maxCost !== null && spentSoFar >= maxCost) {
86
+ tearing = true;
87
+ onTeardown('max-cost');
88
+ return { status: 'timeout', exitCode: null, runtimeMs: elapsedMs, failureType: null };
55
89
  }
56
- lastStatus = status;
57
- }
58
90
 
59
- // Stream any new log lines
60
- try {
61
- const logData = await callApi(`/deployments/${depId}/logs`, {
62
- apiKey: config.apiKey,
63
- baseUrl: config.baseUrl,
64
- });
65
- const lines = logData?.logs ?? [];
66
- for (let i = seenLines; i < lines.length; i++) {
67
- console.log(` ${chalk.dim(lines[i])}`);
91
+ // Hard runtime cap
92
+ if (maxRuntimeMs !== null && elapsedMs >= maxRuntimeMs) {
93
+ tearing = true;
94
+ onTeardown('max-runtime');
95
+ return { status: 'timeout', exitCode: null, runtimeMs: elapsedMs, failureType: null };
96
+ }
97
+
98
+ let dep;
99
+ try {
100
+ dep = await callApi(`/deployments/${depId}`, {
101
+ apiKey: config.apiKey,
102
+ baseUrl: config.baseUrl,
103
+ });
104
+ consecutiveErrs = 0;
105
+ } catch {
106
+ consecutiveErrs++;
107
+ if (lastStatus === 'running') {
108
+ const lostSec = Math.round((consecutiveErrs * POLL_MS) / 1000);
109
+ if (consecutiveErrs === HEARTBEAT_WARN_POLLS) {
110
+ console.log(chalk.yellow(`\n ⚠ No heartbeat for ${lostSec}s — Vast machine may be unresponsive`));
111
+ } else if (consecutiveErrs >= HEARTBEAT_KILL_POLLS) {
112
+ tearing = true;
113
+ onTeardown('heartbeat-lost');
114
+ return { status: 'failed', exitCode: null, runtimeMs: elapsedMs, failureType: 'infrastructure' };
115
+ }
116
+ }
117
+ continue;
68
118
  }
69
- seenLines = lines.length;
70
- } catch {
71
- // logs not ready yet
72
- }
73
119
 
74
- if (TERMINAL.has(status)) {
75
- return status;
120
+ const status = dep.status;
121
+ if (status !== lastStatus) {
122
+ if (status === 'running' && lastStatus === 'provisioning') {
123
+ console.log(chalk.dim(' [running]'));
124
+ }
125
+ lastStatus = status;
126
+ }
127
+
128
+ try {
129
+ const logData = await callApi(`/deployments/${depId}/logs`, {
130
+ apiKey: config.apiKey,
131
+ baseUrl: config.baseUrl,
132
+ });
133
+ const lines = logData?.logs ?? [];
134
+ for (let i = seenLines; i < lines.length; i++) {
135
+ console.log(` ${chalk.dim(lines[i])}`);
136
+ }
137
+ seenLines = lines.length;
138
+ } catch {
139
+ // logs not ready yet
140
+ }
141
+
142
+ if (TERMINAL.has(status)) {
143
+ process.removeListener('SIGINT', sigintHandler);
144
+ const exitCode = dep.exit_code ?? null;
145
+ return {
146
+ status,
147
+ exitCode,
148
+ runtimeMs: Date.now() - startMs,
149
+ failureType: classifyFailure(status, exitCode),
150
+ };
151
+ }
76
152
  }
153
+ } finally {
154
+ process.removeListener('SIGINT', sigintHandler);
77
155
  }
78
156
  }
79
157
 
@@ -88,19 +166,49 @@ export async function runCommand(config, args, chalk) {
88
166
 
89
167
  requireApiKey(config);
90
168
 
91
- const command = positional.length > 0 ? positional : undefined;
92
- const gpu = flags.gpu || 'RTX_4090';
93
- const image = flags.image || (command ? 'python:3.11-slim' : undefined);
94
- const detach = flags.detach || false;
169
+ const command = positional.length > 0 ? positional : undefined;
170
+ const gpu = flags.gpu || 'RTX_4090';
171
+ const image = flags.image || (command ? 'python:3.11-slim' : undefined);
172
+ const detach = flags.detach || false;
173
+ const fallbackMode = flags.noFallback ? 'none' : (flags.fallback || 'closest');
174
+ const maxRuntimeMs = flags.maxRuntime ? flags.maxRuntime * 60 * 1000 : null;
175
+ const maxCost = flags.maxCost ?? null;
95
176
 
96
177
  console.log(chalk.bold('\n⚡ Running GPU job\n'));
97
178
  if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
98
179
  if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
99
180
  console.log(` ${chalk.bold('GPU:')} ${gpu}`);
181
+ if (flags.maxRuntime) console.log(` ${chalk.bold('Max runtime:')} ${flags.maxRuntime}min`);
182
+ if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
183
+ if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice.toFixed(2)}/hr`);
100
184
  if (detach) console.log(` ${chalk.dim('(detached — returns immediately)')}`);
101
185
  console.log();
186
+
187
+ // Safety guidance for jobs without a spend cap
188
+ if (!detach && !flags.maxRuntime && !maxCost) {
189
+ console.log(chalk.dim(' Tip: add --max-runtime 60 or --max-cost 5.00 to cap spend automatically'));
190
+ }
191
+ if (flags.maxRuntime > 60) {
192
+ console.log(chalk.yellow(' Note: jobs over 60min should checkpoint progress to recover from failures'));
193
+ }
194
+
102
195
  console.log(chalk.dim(' Finding best available GPU capacity...'));
103
- console.log(chalk.dim(` API: ${config.baseUrl}`));
196
+ if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
197
+ console.log(chalk.dim(` API: ${config.baseUrl}`));
198
+ }
199
+
200
+ function buildBody(gpuOverride, regionOverride) {
201
+ const effectiveRegion = regionOverride ?? (flags.region ? flags.region.toUpperCase() : undefined);
202
+ return {
203
+ command,
204
+ image,
205
+ gpu: (gpuOverride || gpu).toUpperCase().replace('-', '_'),
206
+ gpu_count: flags.count || 1,
207
+ ...(effectiveRegion ? { region: effectiveRegion } : {}),
208
+ max_price_per_hour: flags.maxPrice,
209
+ name: flags.name,
210
+ };
211
+ }
104
212
 
105
213
  let dep;
106
214
  try {
@@ -108,57 +216,58 @@ export async function runCommand(config, args, chalk) {
108
216
  method: 'POST',
109
217
  apiKey: config.apiKey,
110
218
  baseUrl: config.baseUrl,
111
- body: {
112
- command,
113
- image,
114
- gpu: gpu.toUpperCase().replace('-', '_'),
115
- gpu_count: flags.count || 1,
116
- ...(flags.region ? { region: flags.region.toUpperCase() } : {}),
117
- max_price_per_hour: flags.maxPrice,
118
- name: flags.name,
119
- },
219
+ body: buildBody(),
120
220
  });
121
221
  } catch (err) {
122
222
  const d = err.errorData;
123
223
  if (d?.code === 'NO_CAPACITY_MATCH') {
124
- const f = d.filters ?? {};
125
- const scope = f.region && f.region !== 'any' ? `in ${f.region}` : 'globally';
126
- console.error(chalk.red(`\n No ${f.gpu || gpu} found under ${f.max_price || '$10/hr'} ${scope}.\n`));
127
-
128
- const sameGpuElsewhere = Array.isArray(d.same_gpu_other_regions) ? d.same_gpu_other_regions : [];
129
- const alternatives = Array.isArray(d.alternatives) ? d.alternatives : [];
130
- const cmdPrefix = command ? command.join(' ') : flags.image ? `--image ${flags.image}` : '...';
131
-
132
- if (sameGpuElsewhere.length > 0) {
133
- // Requested GPU exists, but not in the specified region.
134
- console.error(chalk.dim(` ${f.gpu} is available in other regions:\n`));
135
- console.error(chalk.dim(' Try:'));
136
- for (const a of sameGpuElsewhere) {
137
- console.error(chalk.cyan(` badgr run ${cmdPrefix} --gpu ${a.gpu} --region ${a.region}`));
138
- console.error(chalk.dim(` from $${a.price.toFixed(2)}/hr`));
139
- }
140
- } else if (alternatives.length > 0) {
141
- // Global search failed — show what is actually available.
142
- console.error(chalk.dim(' Available right now:\n'));
143
- for (const a of alternatives) {
144
- console.error(chalk.dim(` • ${a.gpu} in ${a.region} from $${a.price.toFixed(2)}/hr`));
145
- }
146
- console.error(chalk.dim('\n Try:'));
147
- for (const a of alternatives) {
148
- console.error(chalk.cyan(` badgr run ${cmdPrefix} --gpu ${a.gpu}`));
149
- }
150
- } else {
151
- console.error(chalk.dim(' No alternative GPU capacity found right now.'));
224
+ if (fallbackMode === 'none') {
225
+ console.error(chalk.red(`\n ✗ ${gpu} isn't available right now.\n`));
226
+ console.error(chalk.dim(' Pass --fallback closest to auto-select an alternative.'));
227
+ process.exit(1);
228
+ }
229
+
230
+ // Merge same-GPU-other-regions and cross-GPU alternatives into one ranked list.
231
+ const pool = [
232
+ ...(Array.isArray(d.same_gpu_other_regions) ? d.same_gpu_other_regions : []),
233
+ ...(Array.isArray(d.alternatives) ? d.alternatives : []),
234
+ ];
235
+
236
+ if (pool.length === 0) {
237
+ console.error(chalk.red(`\n ✗ ${gpu} isn't available right now and no alternatives found.\n`));
152
238
  console.error(chalk.dim(' Try increasing --max-price or contact support for a manual quote.'));
239
+ process.exit(1);
240
+ }
241
+
242
+ const ranked = rankAlternatives(gpu, pool, fallbackMode);
243
+ const chosen = await promptFallback(gpu, ranked, chalk);
244
+
245
+ if (!chosen) {
246
+ console.log(chalk.dim('\n Cancelled.\n'));
247
+ process.exit(0);
248
+ }
249
+
250
+ console.log(chalk.dim(`\n Running on ${chosen.gpu} in ${chosen.region}...\n`));
251
+
252
+ try {
253
+ dep = await callApi('/run', {
254
+ method: 'POST',
255
+ apiKey: config.apiKey,
256
+ baseUrl: config.baseUrl,
257
+ body: buildBody(chosen.gpu, chosen.region),
258
+ });
259
+ } catch (err2) {
260
+ console.error(chalk.red(`\n ✗ Job failed to start on ${chosen.gpu}: ${err2.message}`));
261
+ console.error(chalk.dim(`\n Debug: BADGR_DEBUG=1 badgr run … — shows full request/response\n`));
262
+ process.exit(1);
153
263
  }
154
- console.error('');
155
264
  } else {
156
265
  console.error(chalk.red(`\n ✗ Job failed to start: ${err.message}`));
157
266
  console.error(chalk.dim(`\n Debug: BADGR_DEBUG=1 badgr run … — shows full request/response`));
158
267
  console.error(chalk.dim(` Config: badgr config`));
159
268
  console.error(chalk.dim(` Docs: badgr --help\n`));
269
+ process.exit(1);
160
270
  }
161
- process.exit(1);
162
271
  }
163
272
 
164
273
  const rcptId = dep.receipt_id || generateReceiptId();
@@ -183,18 +292,73 @@ export async function runCommand(config, args, chalk) {
183
292
  return;
184
293
  }
185
294
 
186
- // ── Attached mode: stream logs until job completes ───────────────────────
187
- console.log(chalk.dim('\n ── Attaching (Ctrl+C to detach) ──────────────────────────────\n'));
295
+ // ── Attached mode: stream logs until job completes ────────────────────────
296
+ const ratePerHour = dep.cost_per_hour || 0;
297
+ console.log(chalk.dim('\n ── Attaching (Ctrl+C tears down job) ─────────────────────────\n'));
298
+
299
+ // Teardown helper — called on SIGINT, max-runtime, max-cost, or heartbeat loss.
300
+ async function teardown(reason) {
301
+ const labels = {
302
+ 'max-runtime': chalk.yellow('\n ⏱ Max runtime reached — stopping job...'),
303
+ 'max-cost': chalk.yellow('\n 💰 Spend cap reached — stopping job...'),
304
+ 'heartbeat-lost': chalk.red('\n ✗ Heartbeat lost — infrastructure failure, stopping job...'),
305
+ 'interrupted': chalk.yellow('\n Stopping job...'),
306
+ };
307
+ console.log(labels[reason] ?? chalk.yellow('\n Stopping job...'));
308
+ try {
309
+ await terminateDeployment(config, dep.deployment_id);
310
+ } catch {
311
+ // best-effort
312
+ }
313
+ const runtimeMs = Date.now() - attachStart;
314
+ const finalCost = ratePerHour * (runtimeMs / 3_600_000);
315
+ const failureType = reason === 'heartbeat-lost' ? 'infrastructure' : null;
316
+ updateReceipt(rcptId, { status: reason, runtimeSeconds: Math.round(runtimeMs / 1000), finalCost, failureType });
317
+ console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Est. cost: $${finalCost.toFixed(4)}`));
318
+ console.log(chalk.dim(' Job stopped. Billing ended.\n'));
319
+ process.exit(reason === 'interrupted' ? 0 : 1);
320
+ }
188
321
 
189
- const finalStatus = await attachToJob(config, dep.deployment_id, chalk);
322
+ const attachStart = Date.now();
323
+ const { status: finalStatus, exitCode, runtimeMs, failureType } = await attachToJob(config, dep.deployment_id, {
324
+ chalk,
325
+ maxRuntimeMs,
326
+ maxCost,
327
+ ratePerHour,
328
+ onTeardown: teardown,
329
+ });
190
330
 
191
331
  console.log();
192
332
 
193
- if (finalStatus === 'failed') {
194
- console.error(chalk.red(`\n ✗ Job failed (${dep.deployment_id})\n`));
333
+ const finalCost = ratePerHour * (runtimeMs / 3_600_000);
334
+ updateReceipt(rcptId, {
335
+ status: finalStatus,
336
+ exitCode,
337
+ runtimeSeconds: Math.round(runtimeMs / 1000),
338
+ finalCost,
339
+ failureType,
340
+ });
341
+
342
+ // ── Job summary ──────────────────────────────────────────────────────────
343
+ console.log(` ${chalk.bold('Runtime:')} ${fmtRuntime(runtimeMs)}`);
344
+ if (ratePerHour > 0) {
345
+ console.log(` ${chalk.bold('Cost:')} $${finalCost.toFixed(4)} (${chalk.dim(`$${ratePerHour.toFixed(2)}/hr`)})`);
346
+ }
347
+ if (exitCode !== null && exitCode !== undefined) {
348
+ console.log(` ${chalk.bold('Exit code:')} ${exitCode !== 0 ? chalk.red(exitCode) : chalk.green(exitCode)}`);
349
+ }
350
+
351
+ if (finalStatus === 'failed' || (exitCode !== null && exitCode !== 0)) {
352
+ const label = failureType === 'infrastructure'
353
+ ? chalk.red(`\n ✗ Infrastructure failure (${dep.deployment_id}) — not your code\n`)
354
+ : chalk.red(`\n ✗ Job failed (${dep.deployment_id})\n`);
355
+ console.error(label);
356
+ if (failureType === 'infrastructure') {
357
+ console.error(chalk.dim(' This is a provider-side failure. Contact support with your receipt ID.'));
358
+ }
195
359
  console.error(chalk.dim(` Logs: badgr logs ${dep.deployment_id}\n`));
196
- process.exit(1);
360
+ process.exit(exitCode ?? 1);
197
361
  } else {
198
- console.log(chalk.green(`\n ✓ Job complete (${finalStatus})\n`));
362
+ console.log(chalk.green(`\n ✓ Job complete\n`));
199
363
  }
200
364
  }
@@ -1,6 +1,7 @@
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 { rankAlternatives, promptFallback } from '../fallback.js';
4
5
 
5
6
  /**
6
7
  * badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S
@@ -69,7 +70,11 @@ export async function serveCommand(config, args, chalk) {
69
70
  console.log(` ${chalk.bold('GPU:')} ${gpu}`);
70
71
  console.log();
71
72
  console.log(chalk.dim(' Finding best available GPU capacity...'));
72
- console.log(chalk.dim(` API: ${config.baseUrl}`));
73
+ if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
74
+ console.log(chalk.dim(` API: ${config.baseUrl}`));
75
+ }
76
+
77
+ const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
73
78
 
74
79
  let dep;
75
80
  try {
@@ -81,17 +86,73 @@ export async function serveCommand(config, args, chalk) {
81
86
  model,
82
87
  gpu: gpu.toUpperCase().replace('-', '_'),
83
88
  gpu_count: flags.count || 1,
84
- region: flags.region || 'US',
89
+ ...(effectiveRegion ? { region: effectiveRegion } : {}),
85
90
  max_price_per_hour: flags.maxPrice,
86
91
  name: flags.name,
87
92
  },
88
93
  });
89
94
  } catch (err) {
90
- console.error(chalk.red(`\n ✗ Serve failed: ${err.message}`));
91
- console.error(chalk.dim(`\n Debug: BADGR_DEBUG=1 badgr serve … — shows full request/response`));
92
- console.error(chalk.dim(` Config: badgr config`));
93
- console.error(chalk.dim(` Docs: badgr --help\n`));
94
- return;
95
+ const d = err.errorData;
96
+ if (d?.code === 'NO_CAPACITY_MATCH') {
97
+ const pool = [
98
+ ...(Array.isArray(d.same_gpu_other_regions) ? d.same_gpu_other_regions : []),
99
+ ...(Array.isArray(d.alternatives) ? d.alternatives : []),
100
+ ];
101
+
102
+ if (pool.length === 0) {
103
+ console.error(chalk.red(`\n ✗ ${gpu} isn't available right now and no alternatives found.\n`));
104
+ console.error(chalk.dim(' Try increasing --max-price or contact support for a manual quote.'));
105
+ process.exit(1);
106
+ }
107
+
108
+ const ranked = rankAlternatives(gpu, pool, 'closest');
109
+ const chosen = await promptFallback(gpu, ranked, chalk);
110
+
111
+ if (!chosen) {
112
+ console.log(chalk.dim('\n Cancelled.\n'));
113
+ process.exit(0);
114
+ }
115
+
116
+ console.log(chalk.dim(`\n Serving on ${chosen.gpu} in ${chosen.region}...\n`));
117
+
118
+ try {
119
+ dep = await callApi('/serve', {
120
+ method: 'POST',
121
+ apiKey: config.apiKey,
122
+ baseUrl: config.baseUrl,
123
+ body: {
124
+ model,
125
+ gpu: chosen.gpu.toUpperCase().replace('-', '_'),
126
+ gpu_count: flags.count || 1,
127
+ region: chosen.region,
128
+ max_price_per_hour: flags.maxPrice,
129
+ name: flags.name,
130
+ },
131
+ });
132
+ } catch (err2) {
133
+ console.error(chalk.red(`\n ✗ Serve failed on ${chosen.gpu}: ${err2.message}`));
134
+ console.error(chalk.dim(`\n Debug: BADGR_DEBUG=1 badgr serve … — shows full request/response\n`));
135
+ process.exit(1);
136
+ }
137
+ } else {
138
+ // Write a failure receipt so every serve attempt is auditable.
139
+ const failRcptId = generateReceiptId();
140
+ addReceipt({
141
+ receiptId: failRcptId,
142
+ action: 'badgr serve',
143
+ model,
144
+ gpu,
145
+ status: 'failed',
146
+ failureType: 'infrastructure',
147
+ createdAt: new Date().toISOString(),
148
+ });
149
+ console.error(chalk.red(`\n ✗ Serve failed: ${err.message}`));
150
+ console.error(chalk.dim(`\n Receipt: ${failRcptId}`));
151
+ console.error(chalk.dim(` Debug: BADGR_DEBUG=1 badgr serve … — shows full request/response`));
152
+ console.error(chalk.dim(` Config: badgr config`));
153
+ console.error(chalk.dim(` Docs: badgr --help\n`));
154
+ return;
155
+ }
95
156
  }
96
157
 
97
158
  // Mirror to local store so badgr down/status/receipts work offline
@@ -127,21 +188,27 @@ export async function serveCommand(config, args, chalk) {
127
188
  let endpointReady = false;
128
189
 
129
190
  if (flags.noWait) {
130
- console.log(chalk.yellow('\n⏳ Endpoint provisioning (skipped health check — use --no-wait)\n'));
191
+ console.log(chalk.yellow('\n⏳ Skipped health check (--no-wait)\n'));
131
192
  } else {
132
193
  console.log(chalk.dim('\n Health-checking endpoint (up to 5 min)...'));
133
194
  endpointReady = await waitForEndpoint(endpointUrl, 5 * 60 * 1000, chalk);
134
195
  process.stdout.write('\n');
196
+ if (!endpointReady) {
197
+ updateReceipt(rcptId, { status: 'health_check_timeout' });
198
+ }
135
199
  }
136
200
 
137
201
  // ── Print result ──────────────────────────────────────────────────────────
138
202
  if (endpointReady) {
139
203
  console.log(chalk.green('✓ Endpoint ready\n'));
140
204
  } else {
141
- console.log(chalk.yellow('⏳ Endpoint starting\n'));
142
- console.log(chalk.dim(` Not yet responding. Check status:`));
205
+ console.log(chalk.yellow('⏳ Endpoint still starting (health check timed out)\n'));
206
+ console.log(chalk.dim(' The deployment was provisioned but did not respond within 5 minutes.'));
207
+ console.log(chalk.dim(' It may still be loading the model. Check:'));
143
208
  console.log(chalk.dim(` badgr status`));
209
+ console.log(chalk.dim(` badgr logs ${dep.deployment_id}`));
144
210
  console.log(chalk.dim(` curl ${endpointUrl}/models`));
211
+ console.log(chalk.dim(` If it never starts, run: badgr down ${dep.deployment_id}`));
145
212
  console.log();
146
213
  }
147
214
 
@@ -150,6 +217,7 @@ export async function serveCommand(config, args, chalk) {
150
217
  console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
151
218
  console.log(` ${chalk.bold('Endpoint:')} ${chalk.cyan(endpointUrl)}`);
152
219
  if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
220
+ console.log(` ${chalk.bold('Logs:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
153
221
  console.log(`\n ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
154
222
 
155
223
  if (endpointReady) {
@@ -0,0 +1,123 @@
1
+ import readline from 'readline';
2
+
3
+ // Static GPU metadata for local ranking. Routing still happens server-side.
4
+ const GPU_META = {
5
+ RTX_3080: { vramGb: 10, family: 'consumer', tags: ['inference', 'dev'], desc: 'Dev, light inference' },
6
+ RTX_4080: { vramGb: 16, family: 'consumer', tags: ['inference', 'dev'], desc: 'Inference and dev workloads' },
7
+ RTX_4090: { vramGb: 24, family: 'consumer', tags: ['inference', 'training', 'dev'], desc: 'Inference, training, dev' },
8
+ L40S: { vramGb: 48, family: 'datacenter', tags: ['inference', 'training'], desc: 'Inference, vLLM, batch jobs' },
9
+ A6000: { vramGb: 48, family: 'datacenter', tags: ['training', 'inference'], desc: 'Training, large models, inference' },
10
+ A100: { vramGb: 40, family: 'datacenter', tags: ['training', 'inference'], desc: 'Large-scale training and inference' },
11
+ H100: { vramGb: 80, family: 'datacenter', tags: ['training', 'large-model'], desc: 'Large model training, best throughput' },
12
+ };
13
+
14
+ function altScore(alt, reqMeta) {
15
+ const meta = GPU_META[alt.gpu] ?? { vramGb: 0, family: 'unknown', tags: [] };
16
+ let s = 0;
17
+
18
+ // 1. GPU class/family similarity
19
+ if (meta.family === reqMeta.family) s += 200;
20
+
21
+ // 2. VRAM — prefer same or more, penalize less
22
+ const vramDiff = meta.vramGb - reqMeta.vramGb;
23
+ if (vramDiff >= 0) {
24
+ s += 80 - Math.min(vramDiff, 40) * 0.5;
25
+ } else {
26
+ s += 80 + vramDiff * 4;
27
+ }
28
+
29
+ // 3. Workload tag overlap
30
+ s += reqMeta.tags.filter(t => meta.tags.includes(t)).length * 20;
31
+
32
+ // 4. Availability confidence — not in alternatives payload, treated equal
33
+
34
+ // 5. Price — lower wins
35
+ s -= alt.price * 10;
36
+
37
+ return s;
38
+ }
39
+
40
+ /**
41
+ * Rank a list of { gpu, region, price } alternatives.
42
+ * mode 'closest' → family / VRAM / tag / price (default)
43
+ * mode 'cheapest' → price ascending only
44
+ */
45
+ export function rankAlternatives(requestedGpu, alternatives, mode = 'closest') {
46
+ if (!alternatives || alternatives.length === 0) return [];
47
+ if (mode === 'cheapest') return [...alternatives].sort((a, b) => a.price - b.price);
48
+
49
+ const reqMeta = GPU_META[requestedGpu] ?? { vramGb: 0, family: 'unknown', tags: [] };
50
+ return [...alternatives].sort((a, b) => altScore(b, reqMeta) - altScore(a, reqMeta));
51
+ }
52
+
53
+ /**
54
+ * One-line human-readable diff between requested and chosen GPU.
55
+ * e.g. "less VRAM (24GB vs 40GB), different GPU class than A100"
56
+ */
57
+ export function diffDescription(requestedGpu, altGpu) {
58
+ const req = GPU_META[requestedGpu];
59
+ const alt = GPU_META[altGpu];
60
+ if (!req || !alt) return '';
61
+
62
+ const parts = [];
63
+ const vramDiff = alt.vramGb - req.vramGb;
64
+ if (vramDiff < -4) parts.push(`less VRAM (${alt.vramGb}GB vs ${req.vramGb}GB)`);
65
+ else if (vramDiff > 4) parts.push(`more VRAM (${alt.vramGb}GB vs ${req.vramGb}GB)`);
66
+
67
+ if (alt.family !== req.family) parts.push('different GPU class');
68
+
69
+ if (parts.length === 0) return `similar specs to ${requestedGpu}`;
70
+ return parts.join(', ') + ` than ${requestedGpu}`;
71
+ }
72
+
73
+ function ask(prompt) {
74
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
75
+ return new Promise(resolve => rl.question(prompt, ans => { rl.close(); resolve(ans.trim()); }));
76
+ }
77
+
78
+ /**
79
+ * Show the interactive fallback prompt and return the chosen alternative or null.
80
+ * Non-TTY environments auto-select the top-ranked option.
81
+ */
82
+ export async function promptFallback(requestedGpu, ranked, chalk) {
83
+ if (ranked.length === 0) return null;
84
+
85
+ const top = ranked[0];
86
+ const others = ranked.slice(1, 4); // cap at 3 extras for readability
87
+ const topMeta = GPU_META[top.gpu];
88
+
89
+ console.log(chalk.yellow(`\n ${requestedGpu} isn't available right now.\n`));
90
+ console.log(chalk.bold(' Closest match:'));
91
+ console.log(` ${chalk.cyan(top.gpu)} in ${top.region}`);
92
+ console.log(` ${chalk.green('$' + top.price.toFixed(2) + '/hr')} estimated`);
93
+ if (topMeta?.desc) console.log(` ${topMeta.desc}`);
94
+ console.log();
95
+
96
+ if (!process.stdin.isTTY) {
97
+ console.log(chalk.dim(` Auto-selecting ${top.gpu} (non-interactive).`));
98
+ return top;
99
+ }
100
+
101
+ console.log(` Press ${chalk.bold('Enter')} to run on ${chalk.cyan(top.gpu)}`);
102
+ if (others.length > 0) {
103
+ console.log(' or type:');
104
+ for (const [i, alt] of others.entries()) {
105
+ const meta = GPU_META[alt.gpu];
106
+ const hint = meta ? meta.desc.split(',')[0].toLowerCase() : '';
107
+ console.log(` ${chalk.bold(String(i + 1))} = ${alt.gpu}${hint ? ', ' + hint : ''}`);
108
+ }
109
+ }
110
+ console.log(` ${chalk.bold('q')} = cancel`);
111
+ console.log();
112
+
113
+ const answer = await ask(' > ');
114
+
115
+ if (answer === '') return top;
116
+ if (answer.toLowerCase() === 'q') return null;
117
+
118
+ const idx = parseInt(answer, 10);
119
+ if (!isNaN(idx) && idx >= 1 && idx <= others.length) return others[idx - 1];
120
+
121
+ console.log(chalk.dim(` Unrecognised input — using ${top.gpu}.`));
122
+ return top;
123
+ }
package/src/store.js CHANGED
@@ -83,6 +83,15 @@ export function addReceipt(receipt, storeFile = STORE_FILE) {
83
83
  return receipt;
84
84
  }
85
85
 
86
+ export function updateReceipt(receiptId, updates, storeFile = STORE_FILE) {
87
+ const store = loadStore(storeFile);
88
+ const idx = store.receipts.findIndex(r => r.receiptId === receiptId);
89
+ if (idx === -1) return null;
90
+ store.receipts[idx] = { ...store.receipts[idx], ...updates };
91
+ saveStore(store, storeFile);
92
+ return store.receipts[idx];
93
+ }
94
+
86
95
  export function listReceipts(limit = 20, storeFile = STORE_FILE) {
87
96
  return loadStore(storeFile).receipts.slice(0, limit);
88
97
  }
@@ -1,6 +1,7 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { parseRunArgs } from '../src/commands/run.js';
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { parseRunArgs, classifyFailure } from '../src/commands/run.js';
3
3
  import { parseServeArgs } from '../src/commands/serve.js';
4
+ import { rankAlternatives, diffDescription, promptFallback } from '../src/fallback.js';
4
5
 
5
6
  describe('parseRunArgs', () => {
6
7
  it('parses a plain command', () => {
@@ -43,6 +44,112 @@ describe('parseRunArgs', () => {
43
44
  expect(positional).toEqual([]);
44
45
  expect(flags.gpu).toBeUndefined();
45
46
  });
47
+
48
+ it('parses --fallback closest', () => {
49
+ const { flags } = parseRunArgs(['python', 'train.py', '--gpu', 'A100', '--fallback', 'closest']);
50
+ expect(flags.fallback).toBe('closest');
51
+ expect(flags.noFallback).toBeUndefined();
52
+ });
53
+
54
+ it('parses --fallback cheapest', () => {
55
+ const { flags } = parseRunArgs(['python', 'train.py', '--fallback', 'cheapest']);
56
+ expect(flags.fallback).toBe('cheapest');
57
+ });
58
+
59
+ it('parses --no-fallback', () => {
60
+ const { flags } = parseRunArgs(['python', 'train.py', '--gpu', 'A100', '--no-fallback']);
61
+ expect(flags.noFallback).toBe(true);
62
+ expect(flags.fallback).toBeUndefined();
63
+ });
64
+
65
+ it('parses --max-runtime as a float (minutes)', () => {
66
+ const { flags } = parseRunArgs(['python', 'train.py', '--max-runtime', '30']);
67
+ expect(flags.maxRuntime).toBe(30);
68
+ });
69
+
70
+ it('parses --max-cost as a float (dollars)', () => {
71
+ const { flags } = parseRunArgs(['python', 'train.py', '--max-cost', '5.00']);
72
+ expect(flags.maxCost).toBe(5.0);
73
+ });
74
+ });
75
+
76
+ describe('classifyFailure', () => {
77
+ it('returns infrastructure when status is failed and no exit code', () => {
78
+ expect(classifyFailure('failed', null)).toBe('infrastructure');
79
+ expect(classifyFailure('failed', undefined)).toBe('infrastructure');
80
+ });
81
+
82
+ it('returns customer_code when exit code is non-zero', () => {
83
+ expect(classifyFailure('failed', 1)).toBe('customer_code');
84
+ expect(classifyFailure('completed', 2)).toBe('customer_code');
85
+ });
86
+
87
+ it('returns null for successful jobs', () => {
88
+ expect(classifyFailure('completed', 0)).toBeNull();
89
+ expect(classifyFailure('completed', null)).toBeNull();
90
+ });
91
+
92
+ it('returns null for stopped jobs with no exit code', () => {
93
+ expect(classifyFailure('stopped', null)).toBeNull();
94
+ });
95
+ });
96
+
97
+ describe('rankAlternatives', () => {
98
+ const pool = [
99
+ { gpu: 'RTX_4090', region: 'US', price: 0.72 },
100
+ { gpu: 'L40S', region: 'US', price: 1.25 },
101
+ { gpu: 'H100', region: 'EU', price: 2.80 },
102
+ { gpu: 'A6000', region: 'US', price: 1.20 },
103
+ ];
104
+
105
+ it('cheapest mode sorts by price ascending', () => {
106
+ const ranked = rankAlternatives('A100', pool, 'cheapest');
107
+ expect(ranked[0].price).toBe(0.72);
108
+ expect(ranked[ranked.length - 1].price).toBe(2.80);
109
+ });
110
+
111
+ it('closest mode prefers same GPU family over cheaper consumer GPU', () => {
112
+ const ranked = rankAlternatives('A100', pool, 'closest');
113
+ const topGpu = ranked[0].gpu;
114
+ // H100, A6000, and L40S are all datacenter — any of them should beat RTX_4090
115
+ expect(['H100', 'A6000', 'L40S']).toContain(topGpu);
116
+ const rtxPos = ranked.findIndex(a => a.gpu === 'RTX_4090');
117
+ expect(rtxPos).toBeGreaterThan(0);
118
+ });
119
+
120
+ it('returns empty array for empty pool', () => {
121
+ expect(rankAlternatives('A100', [])).toEqual([]);
122
+ });
123
+
124
+ it('returns empty array for null pool', () => {
125
+ expect(rankAlternatives('A100', null)).toEqual([]);
126
+ });
127
+ });
128
+
129
+ describe('diffDescription', () => {
130
+ it('reports more VRAM when alt has significantly more', () => {
131
+ const desc = diffDescription('RTX_4090', 'L40S'); // 24GB vs 48GB
132
+ expect(desc).toContain('more VRAM');
133
+ });
134
+
135
+ it('reports less VRAM when alt has significantly less', () => {
136
+ const desc = diffDescription('A100', 'RTX_4090'); // 40GB vs 24GB
137
+ expect(desc).toContain('less VRAM');
138
+ });
139
+
140
+ it('reports different GPU class when family differs', () => {
141
+ const desc = diffDescription('A100', 'RTX_4090');
142
+ expect(desc).toContain('different GPU class');
143
+ });
144
+
145
+ it('returns similar specs for same-class same VRAM GPUs', () => {
146
+ const desc = diffDescription('L40S', 'A6000'); // both 48GB, both datacenter
147
+ expect(desc).toContain('similar specs');
148
+ });
149
+
150
+ it('returns empty string for unknown GPUs', () => {
151
+ expect(diffDescription('UNKNOWN', 'ALSO_UNKNOWN')).toBe('');
152
+ });
46
153
  });
47
154
 
48
155
  describe('parseServeArgs', () => {
@@ -79,3 +186,26 @@ describe('parseServeArgs', () => {
79
186
  expect(model).toBeNull();
80
187
  });
81
188
  });
189
+
190
+ describe('promptFallback output', () => {
191
+ it('does not print a Difference line', async () => {
192
+ const lines = [];
193
+ const chalk = { yellow: s => s, bold: s => s, cyan: s => s, green: s => s, dim: s => s };
194
+ const origLog = console.log;
195
+ console.log = (...args) => lines.push(args.join(' '));
196
+ // force non-TTY so it auto-selects without asking
197
+ const origIsTTY = process.stdin.isTTY;
198
+ process.stdin.isTTY = false;
199
+
200
+ const pool = [{ gpu: 'L40S', region: 'US', price: 1.25 }];
201
+ await promptFallback('A100', pool, chalk);
202
+
203
+ console.log = origLog;
204
+ process.stdin.isTTY = origIsTTY;
205
+
206
+ const joined = lines.join('\n');
207
+ expect(joined).not.toContain('Difference');
208
+ expect(joined).toContain('L40S');
209
+ expect(joined).toContain('1.25');
210
+ });
211
+ });
@@ -5,7 +5,7 @@ import { rmSync, existsSync } from 'fs';
5
5
  import {
6
6
  loadStore, saveStore,
7
7
  addDeployment, updateDeployment, removeDeployment, findDeployment, listDeployments,
8
- addReceipt, listReceipts,
8
+ addReceipt, updateReceipt, listReceipts,
9
9
  generateDeploymentId, generateReceiptId,
10
10
  } from '../src/store.js';
11
11
 
@@ -124,3 +124,43 @@ describe('addReceipt / listReceipts', () => {
124
124
  expect(listReceipts(3, file)).toHaveLength(3);
125
125
  });
126
126
  });
127
+
128
+ describe('updateReceipt', () => {
129
+ it('merges updates into an existing receipt', () => {
130
+ addReceipt({ receiptId: 'r-upd', action: 'badgr run', status: 'running' }, file);
131
+ updateReceipt('r-upd', { status: 'completed', finalCost: 0.012, runtimeSeconds: 42 }, file);
132
+ const [r] = listReceipts(1, file);
133
+ expect(r.status).toBe('completed');
134
+ expect(r.finalCost).toBe(0.012);
135
+ expect(r.runtimeSeconds).toBe(42);
136
+ expect(r.action).toBe('badgr run'); // preserved
137
+ });
138
+
139
+ it('returns null for unknown receipt id', () => {
140
+ expect(updateReceipt('no-such-id', { status: 'done' }, file)).toBeNull();
141
+ });
142
+
143
+ it('records a serve failure receipt with failureType=infrastructure', () => {
144
+ addReceipt({
145
+ receiptId: 'r-serve-fail',
146
+ action: 'badgr serve',
147
+ model: 'meta-llama/Llama-3.1-8B-Instruct',
148
+ gpu: 'L40S',
149
+ status: 'failed',
150
+ failureType: 'infrastructure',
151
+ createdAt: new Date().toISOString(),
152
+ }, file);
153
+ const [r] = listReceipts(1, file);
154
+ expect(r.failureType).toBe('infrastructure');
155
+ expect(r.action).toBe('badgr serve');
156
+ expect(r.status).toBe('failed');
157
+ });
158
+
159
+ it('updates a serve receipt to health_check_timeout', () => {
160
+ addReceipt({ receiptId: 'r-hc', action: 'badgr serve', status: 'provisioning' }, file);
161
+ updateReceipt('r-hc', { status: 'health_check_timeout' }, file);
162
+ const receipts = listReceipts(10, file);
163
+ const r = receipts.find(x => x.receiptId === 'r-hc');
164
+ expect(r.status).toBe('health_check_timeout');
165
+ });
166
+ });