badgr-cli 1.0.27 → 1.0.29

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.27",
3
+ "version": "1.0.29",
4
4
  "description": "Badgr, run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.js CHANGED
@@ -59,6 +59,24 @@ export async function callApi(path, { method = 'GET', apiKey, baseUrl, body } =
59
59
  detail = rawBody || res.statusText || '';
60
60
  }
61
61
  const isCapacityError = errorData?.code === 'NO_CAPACITY_MATCH';
62
+ if (res.status === 402) {
63
+ // Payment required — format a clear, actionable error
64
+ const d = errorData?.detail ?? errorData ?? {};
65
+ const detailObj = typeof d === 'object' ? d : {};
66
+ const balanceUsd = typeof detailObj.balance_usd === 'number' ? detailObj.balance_usd : null;
67
+ const requiredUsd = typeof detailObj.required_usd === 'number' ? detailObj.required_usd : null;
68
+ const topupUrl = detailObj.topup_url || 'https://aibadgr.com/dashboard#billing';
69
+
70
+ let msg = '\nPayment required.\n';
71
+ if (balanceUsd !== null) msg += `Your balance is $${balanceUsd.toFixed(2)}.`;
72
+ if (requiredUsd !== null) msg += ` This job needs a $${requiredUsd.toFixed(2)} reserve.`;
73
+ msg += '\n\nAdd balance:\n ' + topupUrl + '\n';
74
+ const err = new Error(msg);
75
+ err.errorData = errorData;
76
+ err.httpStatus = 402;
77
+ err.isPaymentRequired = true;
78
+ throw err;
79
+ }
62
80
  const hint =
63
81
  res.status === 401 ? '\n Hint: Invalid or missing API key — run: badgr login' :
64
82
  res.status === 403 ? '\n Hint: Access denied — check your API key permissions' :
package/src/badgr.js CHANGED
@@ -12,6 +12,7 @@ import { serveCommand } from './commands/serve.js';
12
12
  import { modelsCommand } from './commands/models.js';
13
13
  import { capacityCommand } from './commands/capacity.js';
14
14
  import { testCommand } from './commands/test-run.js';
15
+ import { billingCommand } from './commands/billing.js';
15
16
 
16
17
  const HELP = `
17
18
  ${chalk.bold('badgr')} — run or serve GPU workloads from one command
@@ -26,6 +27,7 @@ ${chalk.bold('COMMANDS')}
26
27
  ${chalk.cyan('badgr receipts')} Show cost history
27
28
  ${chalk.cyan('badgr test')} Run an end-to-end test (provision → run → teardown)
28
29
  ${chalk.cyan('badgr capacity')} Check what GPU capacity is available right now
30
+ ${chalk.cyan('badgr billing')} Show balance and add funds
29
31
 
30
32
  ${chalk.bold('EXAMPLES')}
31
33
  ${chalk.dim('# Verify the stack works end-to-end:')}
@@ -104,6 +106,7 @@ async function main() {
104
106
  case 'models': return modelsCommand(config, chalk);
105
107
  case 'capacity': return capacityCommand(config, rest, chalk);
106
108
  case 'test': return testCommand(config, rest, chalk);
109
+ case 'billing': return billingCommand(config, rest, chalk);
107
110
  // legacy aliases kept for compatibility
108
111
  case 'up': return upCommand(config, rest, chalk);
109
112
  case 'config': {
@@ -0,0 +1,93 @@
1
+ import { execSync } from 'child_process';
2
+ import { requireApiKey } from '../config.js';
3
+ import { callApi } from '../api.js';
4
+
5
+ const BILLING_HELP = `
6
+ badgr billing — manage your AI Badgr balance
7
+
8
+ COMMANDS
9
+ badgr billing status Show current balance
10
+ badgr billing add <amount> Open checkout to add balance (minimum $10)
11
+
12
+ EXAMPLES
13
+ badgr billing status
14
+ badgr billing add 10
15
+ badgr billing add 20
16
+ badgr billing add 50
17
+ `;
18
+
19
+ function openBrowser(url) {
20
+ const platform = process.platform;
21
+ try {
22
+ if (platform === 'darwin') execSync(`open "${url}"`);
23
+ else if (platform === 'win32') execSync(`start "" "${url}"`);
24
+ else execSync(`xdg-open "${url}"`);
25
+ } catch {
26
+ // Silently ignore — we print the URL anyway
27
+ }
28
+ }
29
+
30
+ async function billingStatus(config, chalk) {
31
+ requireApiKey(config);
32
+ try {
33
+ const apiUrl = config.baseUrl.replace('/v1', '').replace('/api/v1', '');
34
+ const data = await callApi('/api/me', {
35
+ apiKey: config.apiKey,
36
+ baseUrl: apiUrl,
37
+ });
38
+ const credits = data.credits ?? 0;
39
+ const balanceUsd = (credits / 10000).toFixed(2);
40
+ console.log();
41
+ console.log(chalk.bold(' Balance'));
42
+ console.log(` ${chalk.bold(chalk.blue(`$${balanceUsd}`))}`);
43
+ console.log();
44
+ if (credits === 0) {
45
+ console.log(chalk.yellow(' Balance is $0.00. Add balance before making API calls or running GPU jobs.'));
46
+ console.log(chalk.dim(' Add balance: badgr billing add 10'));
47
+ console.log(chalk.dim(' Or visit: https://aibadgr.com/billing/top-up'));
48
+ }
49
+ console.log();
50
+ } catch (err) {
51
+ if (err.isPaymentRequired) {
52
+ console.error(err.message);
53
+ process.exit(1);
54
+ }
55
+ console.error(chalk.red(' Could not fetch balance: ' + err.message));
56
+ process.exit(1);
57
+ }
58
+ }
59
+
60
+ async function billingAdd(config, amount, chalk) {
61
+ requireApiKey(config);
62
+ const amountInt = parseInt(amount, 10);
63
+ if (!amountInt || amountInt < 10) {
64
+ console.error(chalk.red(' Minimum top-up is $10. Example: badgr billing add 10'));
65
+ process.exit(1);
66
+ }
67
+
68
+ const url = `https://aibadgr.com/dashboard#billing`;
69
+ console.log();
70
+ console.log(chalk.bold(` Opening dashboard billing to add $${amountInt}...`));
71
+ console.log();
72
+ console.log(` ${chalk.dim(url)}`);
73
+ console.log();
74
+ openBrowser(url);
75
+ console.log(chalk.dim(' Complete payment in your browser, then rerun your command.'));
76
+ console.log();
77
+ }
78
+
79
+ export async function billingCommand(config, args, chalk) {
80
+ const [sub, ...rest] = args;
81
+
82
+ if (!sub || sub === '--help' || sub === '-h') {
83
+ console.log(BILLING_HELP);
84
+ return;
85
+ }
86
+
87
+ if (sub === 'status') return billingStatus(config, chalk);
88
+ if (sub === 'add') return billingAdd(config, rest[0], chalk);
89
+
90
+ console.error(chalk.red(` Unknown billing command: ${sub}`));
91
+ console.log(chalk.dim(' Run: badgr billing --help'));
92
+ process.exit(1);
93
+ }
@@ -1,6 +1,12 @@
1
1
  import { findDeployment, listDeployments } from '../store.js';
2
2
  import { requireApiKey } from '../config.js';
3
- import { getDeploymentLogs } from '../api.js';
3
+ import { getDeploymentLogs, callApi } from '../api.js';
4
+
5
+ const TERMINAL_STATUSES = new Set(['succeeded', 'completed', 'failed', 'stopped', 'terminated']);
6
+ const FOLLOW_POLL_MS = 3000;
7
+
8
+ // Lines that carry structured metadata shown elsewhere (status bar in `badgr run`).
9
+ const LOG_META_RE = /^\[dep-[^\]]+\] (status|gpu|region|endpoint|cost|receipt|provider_status|uptime)=/;
4
10
 
5
11
  export async function logsCommand(config, args, chalk) {
6
12
  const idOrName = args.find(a => !a.startsWith('--'));
@@ -26,13 +32,18 @@ export async function logsCommand(config, args, chalk) {
26
32
 
27
33
  console.log(chalk.bold(`\n📋 Logs: ${localDep?.name ?? deploymentId}\n`));
28
34
 
35
+ // Fetch and print initial batch of logs.
36
+ const seen = new Set();
29
37
  try {
30
38
  const data = await getDeploymentLogs(config, deploymentId);
31
39
  const lines = data?.logs ?? [];
32
- if (lines.length === 0) {
40
+ if (lines.length === 0 && !follow) {
33
41
  console.log(chalk.dim(' No log lines available yet.\n'));
34
42
  } else {
35
- lines.forEach(l => console.log(` ${chalk.dim(l)}`));
43
+ for (const line of lines) {
44
+ seen.add(line);
45
+ if (!LOG_META_RE.test(line)) console.log(` ${chalk.dim(line)}`);
46
+ }
36
47
  }
37
48
  } catch (err) {
38
49
  console.log(chalk.yellow(` Could not fetch logs: ${err.message}`));
@@ -40,10 +51,51 @@ export async function logsCommand(config, args, chalk) {
40
51
  console.log(chalk.dim(`\n GPU: ${localDep.gpu} Type: ${localDep.type}`));
41
52
  console.log(chalk.dim(` Endpoint: ${localDep.endpointUrl}`));
42
53
  }
54
+ if (!follow) { console.log(); return; }
43
55
  }
44
56
 
45
- if (follow) {
46
- console.log(chalk.yellow('\n --follow: live log streaming not yet supported. Poll with `badgr logs`.\n'));
57
+ if (!follow) { console.log(); return; }
58
+
59
+ // --follow: poll until the deployment reaches a terminal state.
60
+ console.log(chalk.dim(' (following — Ctrl+C to stop)\n'));
61
+
62
+ let stopping = false;
63
+ process.once('SIGINT', () => { stopping = true; });
64
+
65
+ while (!stopping) {
66
+ await new Promise(r => setTimeout(r, FOLLOW_POLL_MS));
67
+ if (stopping) break;
68
+
69
+ let status = null;
70
+ try {
71
+ const dep = await callApi(`/deployments/${deploymentId}`, {
72
+ apiKey: config.apiKey,
73
+ baseUrl: config.baseUrl,
74
+ });
75
+ status = dep?.status ?? null;
76
+ } catch {
77
+ // network blip — keep following
78
+ }
79
+
80
+ try {
81
+ const data = await getDeploymentLogs(config, deploymentId);
82
+ for (const line of (data?.logs ?? [])) {
83
+ if (seen.has(line)) continue;
84
+ seen.add(line);
85
+ if (!LOG_META_RE.test(line)) {
86
+ const isErr = /^error\b/i.test(line) || /Error response from daemon/i.test(line);
87
+ console.log(` ${isErr ? chalk.red(line) : chalk.dim(line)}`);
88
+ }
89
+ }
90
+ } catch {
91
+ // logs endpoint temporarily unavailable
92
+ }
93
+
94
+ if (status && TERMINAL_STATUSES.has(status)) {
95
+ console.log(chalk.dim(`\n Job ${status}. No more logs.\n`));
96
+ break;
97
+ }
47
98
  }
99
+
48
100
  console.log();
49
101
  }
@@ -431,6 +431,11 @@ export async function runCommand(config, args, chalk) {
431
431
  console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.\n`));
432
432
  }
433
433
  process.exit(1);
434
+ } else if (err.isPaymentRequired) {
435
+ console.error(chalk.yellow(err.message));
436
+ const rerun = ['badgr run', ...args].join(' ');
437
+ console.error(chalk.dim(`After payment, rerun:\n ${rerun}\n`));
438
+ process.exit(1);
434
439
  } else {
435
440
  console.error(chalk.red(`\n ✗ Could not start job: ${err.message}\n`));
436
441
  console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.`));
@@ -445,12 +450,15 @@ export async function runCommand(config, args, chalk) {
445
450
 
446
451
  const rcptId = dep.receipt_id || generateReceiptId();
447
452
  addReceipt({
448
- receiptId: rcptId,
449
- action: 'badgr run',
450
- deploymentId: dep.deployment_id,
451
- gpu: dep.gpu_type,
452
- status: dep.status,
453
- createdAt: new Date().toISOString(),
453
+ receiptId: rcptId,
454
+ action: 'badgr run',
455
+ deploymentId: dep.deployment_id,
456
+ gpu: dep.gpu_type,
457
+ providerRoute: dep.provider ?? null,
458
+ maxCost: maxCost ?? null,
459
+ maxRuntime: flags.maxRuntime ?? null,
460
+ status: dep.status,
461
+ createdAt: new Date().toISOString(),
454
462
  });
455
463
 
456
464
  console.log();
@@ -501,7 +509,7 @@ export async function runCommand(config, args, chalk) {
501
509
  }
502
510
  const runtimeMs = Date.now() - attachStart;
503
511
  const finalCost = ratePerHour * (runtimeMs / 3_600_000);
504
- updateReceipt(rcptId, { status: reason, runtimeSeconds: Math.round(runtimeMs / 1000), finalCost });
512
+ updateReceipt(rcptId, { status: reason, teardownStatus: 'terminated', runtimeSeconds: Math.round(runtimeMs / 1000), finalCost });
505
513
  console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Est. cost: $${finalCost.toFixed(4)}`));
506
514
  console.log(chalk.dim(' Job stopped. Billing ended.\n'));
507
515
  process.exit(reason === 'interrupted' ? 0 : 1);
@@ -520,11 +528,12 @@ export async function runCommand(config, args, chalk) {
520
528
 
521
529
  const finalCost = ratePerHour * (runtimeMs / 3_600_000);
522
530
  updateReceipt(rcptId, {
523
- status: finalStatus,
531
+ status: finalStatus,
524
532
  exitCode,
525
533
  runtimeSeconds: Math.round(runtimeMs / 1000),
526
534
  finalCost,
527
535
  failureType,
536
+ teardownStatus: (finalStatus === 'completed' || finalStatus === 'succeeded') ? 'terminated' : 'failed',
528
537
  });
529
538
 
530
539
  console.log(` ${chalk.bold('Runtime:')} ${fmtRuntime(runtimeMs)}`);
@@ -171,6 +171,12 @@ export async function serveCommand(config, args, chalk) {
171
171
  failureType: 'infrastructure',
172
172
  createdAt: new Date().toISOString(),
173
173
  });
174
+ if (err.isPaymentRequired) {
175
+ console.error(chalk.yellow(err.message));
176
+ const rerun = `badgr serve ${args.join(' ')}`;
177
+ console.error(chalk.dim(`After payment, rerun:\n ${rerun}\n`));
178
+ return;
179
+ }
174
180
  console.error(chalk.red(`\n ✗ Could not start endpoint: ${err.message}`));
175
181
  console.error(chalk.dim(`\n Receipt: ${failRcptId}`));
176
182
  console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
@@ -175,7 +175,8 @@ export async function testCommand(config, args, chalk) {
175
175
  process.stdout.write(chalk.dim(' Waiting for container to start...'));
176
176
  const started = await pollStatus(
177
177
  config, depId,
178
- new Set(['running', 'failed', 'stopped', 'completed']),
178
+ // Modal serverless jobs may skip straight to succeeded when the callback fires.
179
+ new Set(['running', 'starting', 'succeeded', 'failed', 'stopped', 'completed']),
179
180
  TEST_MAX_RUNTIME_MS,
180
181
  );
181
182
  process.stdout.write('\n');