badgr-cli 1.0.12 → 1.0.14

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,10 +1,10 @@
1
1
  {
2
2
  "name": "badgr-cli",
3
- "version": "1.0.12",
4
- "description": "Badgr run or serve GPU workloads from one command",
3
+ "version": "1.0.14",
4
+ "description": "Badgr, run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
7
- "badgr": "./src/badgr.js"
7
+ "badgr": "src/badgr.js"
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node src/badgr.js",
@@ -21,6 +21,14 @@
21
21
  "engines": {
22
22
  "node": ">=18.0.0"
23
23
  },
24
- "keywords": ["gpu", "cli", "ai", "compute", "modal", "gateway", "openai"],
24
+ "keywords": [
25
+ "gpu",
26
+ "cli",
27
+ "ai",
28
+ "compute",
29
+ "modal",
30
+ "gateway",
31
+ "openai"
32
+ ],
25
33
  "license": "MIT"
26
34
  }
package/src/badgr.js CHANGED
@@ -15,64 +15,60 @@ import { capacityCommand } from './commands/capacity.js';
15
15
  const HELP = `
16
16
  ${chalk.bold('badgr')} — run or serve GPU workloads from one command
17
17
 
18
- ${chalk.bold('CORE COMMANDS')}
19
- ${chalk.cyan('badgr login')} Authenticate (save API key to ~/.badgr/config.json)
20
- ${chalk.cyan('badgr run <cmd...> --gpu <type>')} Run a one-off GPU job
21
- ${chalk.cyan('badgr serve <model> --gpu <type>')} Serve a model (OpenAI-compatible endpoint)
22
- ${chalk.cyan('badgr status')} Show active deployments + endpoint URLs
23
- ${chalk.cyan('badgr logs <id>')} Stream logs for a deployment
24
- ${chalk.cyan('badgr down <id>')} Terminate a deployment (stop billing)
25
- ${chalk.cyan('badgr receipts [<id>|<n>]')} Show receipts — pass ID for single, number for list
26
- ${chalk.cyan('badgr capacity [--gpu <type>]')} Check live GPU availability and alternatives
27
-
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)
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
40
-
41
- ${chalk.bold('badgr serve OPTIONS')}
42
- --gpu <type> GPU: L40S, A100, H100, RTX_4090 (default: L40S)
43
- --count <n> GPU count (default: 1)
44
- --region US|EU|AU Region preference (default: US)
45
- --max-price <$/hr> Hard spend cap per GPU-hour
46
- --name <name> Deployment name (auto-generated if omitted)
18
+ ${chalk.bold('COMMANDS')}
19
+ ${chalk.cyan('badgr login')} Authenticate with your API key
20
+ ${chalk.cyan('badgr run <command>')} Run a one-off GPU job
21
+ ${chalk.cyan('badgr serve <model>')} Serve a model with an OpenAI-compatible endpoint
22
+ ${chalk.cyan('badgr status')} Show what's running and what's billing
23
+ ${chalk.cyan('badgr logs <id>')} Stream logs for a running job or endpoint
24
+ ${chalk.cyan('badgr down <id>')} Stop a deployment and end billing
25
+ ${chalk.cyan('badgr receipts')} Show cost history
26
+ ${chalk.cyan('badgr capacity')} Check what GPU capacity is available right now
47
27
 
48
28
  ${chalk.bold('EXAMPLES')}
49
- badgr login
29
+ ${chalk.dim('# Simplest — Badgr picks the GPU:')}
30
+ badgr run python train.py
31
+ badgr serve meta-llama/Llama-3.1-8B-Instruct
32
+
33
+ ${chalk.dim('# Pin a specific GPU:')}
50
34
  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
54
- badgr run --image my/image:latest --gpu L40S
55
35
  badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S
56
- badgr serve mistralai/Mistral-7B-v0.1 --gpu RTX_4090
36
+
37
+ ${chalk.dim('# Add safety caps:')}
38
+ badgr run python train.py --max-runtime 60 --max-cost 5
39
+
40
+ ${chalk.dim('# Manage a running deployment:')}
57
41
  badgr status
58
- badgr logs dep_abc123
59
- badgr down dep_abc123
60
- badgr receipts
61
- badgr receipts dep_abc123
42
+ badgr logs dep-abc123
43
+ badgr down dep-abc123
44
+ badgr receipts dep-abc123
62
45
 
63
- ${chalk.bold('OPENAI-COMPATIBLE SERVING')}
64
- ${chalk.dim('After `badgr serve`, point any OpenAI client at the returned URL:')}
65
- ${chalk.dim(' client = OpenAI(api_key="sk-...", base_url="https://aibadgr.com/v1")')}
66
- ${chalk.dim(' client.chat.completions.create(model="dep_xxx", messages=[...])')}
46
+ ${chalk.bold('badgr run OPTIONS')}
47
+ --gpu <type> GPU type (default: auto Badgr picks cheapest available)
48
+ --image <image> Docker image (default: python:3.11-slim)
49
+ --count <n> Number of GPUs (default: 1)
50
+ --region US|EU|AU Region preference
51
+ --max-price <$/hr> Hard spend cap per GPU-hour
52
+ --max-runtime <min> Auto-stop after N minutes (recommended)
53
+ --max-cost <$> Auto-stop when spend reaches this amount
54
+ --detach Return immediately, don't stream logs
55
+
56
+ ${chalk.bold('badgr serve OPTIONS')}
57
+ --gpu <type> GPU type (default: auto — inferred from model size)
58
+ --count <n> Number of GPUs (default: 1)
59
+ --region US|EU|AU Region preference
60
+ --max-price <$/hr> Hard spend cap per GPU-hour
61
+ --no-wait Skip endpoint health check
67
62
 
68
- ${chalk.bold('ROUTING')}
69
- ${chalk.dim('Badgr automatically selects best available GPU capacity for your request.')}
63
+ ${chalk.bold('AFTER SERVING')}
64
+ ${chalk.dim('Point any OpenAI client at the returned URL:')}
65
+ ${chalk.dim(' from openai import OpenAI')}
66
+ ${chalk.dim(' client = OpenAI(base_url="<endpoint url>", api_key="<your key>")')}
67
+ ${chalk.dim(' client.chat.completions.create(model="<model>", messages=[...])')}
70
68
 
71
- ${chalk.bold('DEBUGGING')}
72
- ${chalk.dim('Set BADGR_DEBUG=1 to print the full URL, API key prefix, request body, and response status:')}
73
- ${chalk.dim(' BADGR_DEBUG=1 badgr run python train.py --gpu A100')}
74
- ${chalk.dim(' BADGR_DEBUG=1 badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S')}
75
- ${chalk.dim(' badgr config # show current baseUrl and key')}
69
+ ${chalk.bold('DEBUG')}
70
+ ${chalk.dim('BADGR_DEBUG=1 badgr run python train.py # full request/response trace')}
71
+ ${chalk.dim('badgr config # show current API config')}
76
72
  `;
77
73
 
78
74
  async function main() {
@@ -2,15 +2,9 @@ import { requireApiKey } from '../config.js';
2
2
  import { callApi } from '../api.js';
3
3
 
4
4
  /**
5
- * badgr capacity # check RTX_4090 at default $10/hr cap
6
- * badgr capacity --gpu L40S # check a specific GPU type
7
- * badgr capacity --gpu A100 --region EU
8
- * badgr capacity --max-price 5
9
- *
10
- * Internal diagnostic command: shows per-provider availability breakdown.
11
- * Provider display names come from the backend — none are hardcoded here.
5
+ * badgr capacity # show what's available right now (auto)
6
+ * badgr capacity --gpu A100 # check a specific GPU type
12
7
  */
13
-
14
8
  function parseCapacityArgs(args) {
15
9
  const flags = {};
16
10
  let i = 0;
@@ -27,17 +21,50 @@ export async function capacityCommand(config, args, chalk) {
27
21
  const flags = parseCapacityArgs(args);
28
22
  requireApiKey(config);
29
23
 
30
- const gpu = flags.gpu || 'RTX_4090';
31
24
  const maxPrice = flags.maxPrice ?? 10;
32
25
 
26
+ // No --gpu: show cheapest runnable GPU across all types
27
+ if (!flags.gpu) {
28
+ console.log(chalk.bold('\nAvailable GPU capacity\n'));
29
+ process.stdout.write(chalk.dim(' Checking availability...\n'));
30
+
31
+ let data;
32
+ try {
33
+ const params = new URLSearchParams({ max_price: String(maxPrice) });
34
+ if (flags.region) params.set('region', flags.region.toUpperCase());
35
+ data = await callApi(`/capacity/auto?${params}`, {
36
+ apiKey: config.apiKey,
37
+ baseUrl: config.baseUrl,
38
+ });
39
+ } catch (err) {
40
+ if (err.errorData?.code === 'NO_CAPACITY_MATCH') {
41
+ console.log(chalk.dim('\n No GPU capacity available right now.\n'));
42
+ console.log(chalk.dim(' Try again in a few minutes, or check a specific GPU with --gpu <type>.\n'));
43
+ return;
44
+ }
45
+ console.error(chalk.red(`\n ✗ Capacity check failed: ${err.message}\n`));
46
+ process.exit(1);
47
+ }
48
+
49
+ console.log();
50
+ console.log(` ${chalk.bold('Cheapest available:')} ${chalk.cyan(data.gpu)} in ${data.region} ${chalk.green('$' + data.price.toFixed(2) + '/hr')}`);
51
+ console.log();
52
+ console.log(` ${chalk.bold('Run now:')}`);
53
+ console.log(chalk.cyan(` badgr run python train.py`));
54
+ console.log(chalk.cyan(` badgr serve meta-llama/Llama-3.1-8B-Instruct`));
55
+ console.log();
56
+ console.log(chalk.dim(` Use badgr capacity --gpu A100 to check a specific GPU type.`));
57
+ console.log();
58
+ return;
59
+ }
60
+
61
+ // --gpu specified: show availability for that type
62
+ const gpu = flags.gpu.toUpperCase().replace('-', '_');
33
63
  const params = new URLSearchParams({ gpu, max_price: String(maxPrice) });
34
64
  if (flags.region) params.set('region', flags.region.toUpperCase());
35
65
 
36
- console.log(chalk.bold(`\n⚡ GPU Capacity Check\n`));
37
- console.log(` ${chalk.bold('GPU:')} ${gpu}`);
38
- if (flags.region) console.log(` ${chalk.bold('Region:')} ${flags.region.toUpperCase()}`);
39
- console.log(` ${chalk.bold('Max price:')} $${maxPrice.toFixed(2)}/hr`);
40
- console.log();
66
+ console.log(chalk.bold(`\nCapacity: ${gpu}\n`));
67
+ process.stdout.write(chalk.dim(' Checking...\n'));
41
68
 
42
69
  let data;
43
70
  try {
@@ -50,55 +77,35 @@ export async function capacityCommand(config, args, chalk) {
50
77
  process.exit(1);
51
78
  }
52
79
 
53
- // Per-provider breakdown
54
- const diags = data.diagnostics ?? [];
55
- if (diags.length > 0) {
56
- console.log(chalk.bold(' Provider breakdown:'));
57
- for (const d of diags) {
58
- const padded = (d.display_name ?? 'Unknown').padEnd(12);
59
- if (d.status === 'no_key') {
60
- console.log(` ${chalk.dim(padded)} ${chalk.dim('disabled — ' + d.reason)}`);
61
- } else if (d.status === 'error') {
62
- console.log(` ${chalk.dim(padded)} ${chalk.red('error — ' + d.reason)}`);
63
- } else if (d.status === 'ok') {
64
- const price = d.cheapest_price != null ? chalk.green(`cheapest $${d.cheapest_price.toFixed(2)}/hr`) : '';
65
- console.log(` ${chalk.bold(padded)} ${d.eligible_count} eligible ${price}`);
66
- } else {
67
- const reason = d.reason ? chalk.dim(` — ${d.reason}`) : '';
68
- console.log(` ${chalk.dim(padded)} 0 eligible${reason}`);
69
- }
70
- }
71
- console.log();
72
- }
73
-
74
- // Direct matches
75
80
  const matches = data.matches ?? [];
76
81
  if (matches.length > 0) {
77
- console.log(chalk.bold(' Available now:'));
82
+ console.log(chalk.bold('\n Available now:\n'));
78
83
  for (const m of matches) {
79
- console.log(` ${chalk.green('')} ${m.gpu} in ${m.region} $${m.price.toFixed(2)}/hr`);
84
+ console.log(` ${chalk.green('')} ${m.gpu} in ${m.region} ${chalk.green('$' + m.price.toFixed(2) + '/hr')}`);
80
85
  }
81
86
  console.log();
82
- console.log(chalk.bold(' Run:'));
83
- console.log(chalk.cyan(` badgr run ... --gpu ${gpu}${flags.region ? ' --region ' + flags.region.toUpperCase() : ''}`));
87
+ const regionFlag = flags.region ? ` --region ${flags.region.toUpperCase()}` : '';
88
+ console.log(` ${chalk.bold('Run:')}`);
89
+ console.log(chalk.cyan(` badgr run python train.py --gpu ${gpu}${regionFlag}`));
90
+ console.log();
84
91
  } else {
85
- console.log(chalk.dim(` No ${gpu} found under $${maxPrice.toFixed(2)}/hr${flags.region ? ' in ' + flags.region.toUpperCase() : ' globally'}.`));
86
- }
92
+ const regionLabel = flags.region ? ` in ${flags.region.toUpperCase()}` : '';
93
+ console.log(chalk.dim(`\n No ${gpu} available right now under $${maxPrice.toFixed(2)}/hr${regionLabel}.\n`));
87
94
 
88
- // Alternatives
89
- const alternatives = data.alternatives ?? [];
90
- if (alternatives.length > 0 && matches.length === 0) {
91
- console.log();
92
- console.log(chalk.bold(' Alternatives:'));
93
- for (const a of alternatives) {
94
- console.log(` ${chalk.dim('•')} ${a.gpu} in ${a.region} $${a.price.toFixed(2)}/hr`);
95
- }
96
- console.log();
97
- console.log(chalk.bold(' Try:'));
98
- for (const a of alternatives) {
99
- console.log(chalk.cyan(` badgr run ... --gpu ${a.gpu}`));
95
+ const alternatives = data.alternatives ?? [];
96
+ if (alternatives.length > 0) {
97
+ console.log(chalk.bold(' Available alternatives:\n'));
98
+ for (const a of alternatives) {
99
+ console.log(` ${chalk.dim('')} ${a.gpu} in ${a.region} $${a.price.toFixed(2)}/hr`);
100
+ }
101
+ console.log();
102
+ console.log(` ${chalk.bold('Try:')}`);
103
+ for (const a of alternatives.slice(0, 3)) {
104
+ console.log(chalk.cyan(` badgr run python train.py --gpu ${a.gpu}`));
105
+ }
106
+ console.log();
107
+ } else {
108
+ console.log(chalk.dim(' No alternatives found. Try `badgr capacity` to see global availability.\n'));
100
109
  }
101
110
  }
102
-
103
- console.log();
104
111
  }
@@ -12,43 +12,46 @@ export async function downCommand(config, args, chalk) {
12
12
 
13
13
  requireApiKey(config);
14
14
 
15
- const localDep = findDeployment(idOrName);
16
-
17
- // Resolve the deployment ID to send to the backend
15
+ const localDep = findDeployment(idOrName);
18
16
  const deploymentId = localDep?.id ?? idOrName;
19
17
 
20
- console.log(chalk.dim(` Terminating ${deploymentId}...`));
18
+ process.stdout.write(chalk.dim(` Stopping ${deploymentId}...`));
21
19
 
22
20
  let dep;
23
21
  try {
24
22
  dep = await terminateDeployment(config, deploymentId);
25
23
  } catch (err) {
26
- console.error(chalk.red(`\n ✗ Terminate failed: ${err.message}\n`));
24
+ process.stdout.write('\n');
25
+ console.error(chalk.red(`\n ✗ Could not stop deployment: ${err.message}\n`));
27
26
  return;
28
27
  }
29
28
 
29
+ process.stdout.write('\n');
30
+
31
+ // Compute final cost from the deployment timestamps
32
+ const stoppedAt = dep.stopped_at ?? (Date.now() / 1000);
33
+ const startedAt = dep.started_at ?? stoppedAt;
34
+ const runtimeMin = Math.round((stoppedAt - startedAt) / 60);
35
+ const runtimeHr = (stoppedAt - startedAt) / 3600;
36
+ const finalCost = (dep.cost_per_hour || 0) * runtimeHr;
37
+
30
38
  const rcptId = generateReceiptId();
31
39
  addReceipt({
32
- receiptId: rcptId,
33
- action: 'badgr down',
34
- deploymentId: dep.deployment_id,
35
- name: dep.name,
36
- provider: dep.provider,
37
- gpu: dep.gpu_type,
38
- latencyMs: 0,
39
- status: 'terminated',
40
- createdAt: new Date().toISOString(),
40
+ receiptId: rcptId,
41
+ action: 'badgr down',
42
+ deploymentId: dep.deployment_id,
43
+ gpu: dep.gpu_type,
44
+ runtimeSeconds: Math.round(stoppedAt - startedAt),
45
+ finalCost,
46
+ status: 'terminated',
47
+ createdAt: new Date().toISOString(),
41
48
  });
42
49
 
43
- // Remove from local store
44
50
  removeDeployment(idOrName);
45
51
 
46
- console.log(chalk.green(`\n✓ Deployment stopped: ${dep.name}\n`));
47
- console.log(` ${chalk.bold('ID:')} ${dep.deployment_id}`);
48
- console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
49
- if (dep.started_at) {
50
- const uptime = Math.round((Date.now() / 1000 - dep.started_at) / 60);
51
- console.log(` ${chalk.bold('Uptime:')} ${uptime} min`);
52
- }
53
- console.log(`\n ${chalk.bold('Receipt ID:')} ${chalk.dim(rcptId)}\n`);
52
+ console.log(chalk.green('\n✓ Stopped'));
53
+ console.log(chalk.green(' Billing ended\n'));
54
+ console.log(` ${chalk.bold('Runtime:')} ${runtimeMin}m`);
55
+ if (finalCost > 0) console.log(` ${chalk.bold('Final cost:')} $${finalCost.toFixed(4)}`);
56
+ console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}\n`);
54
57
  }
@@ -1,8 +1,9 @@
1
1
  import { input } from '@inquirer/prompts';
2
2
  import { DEFAULTS } from '../config.js';
3
+ import { callApi } from '../api.js';
3
4
 
4
5
  export async function loginCommand(chalk, saveConfigFn) {
5
- console.log(chalk.bold('\n🔑 Badgr Login\n'));
6
+ console.log(chalk.bold('\nBadgr Login\n'));
6
7
 
7
8
  const apiKey = await input({
8
9
  message: 'Enter your Badgr API key:',
@@ -13,7 +14,23 @@ export async function loginCommand(chalk, saveConfigFn) {
13
14
  apiKey: apiKey.trim(),
14
15
  baseUrl: DEFAULTS.baseUrl,
15
16
  });
16
- console.log(chalk.green('\n✓ Logged in — config saved to ~/.badgr/config.json\n'));
17
- console.log(chalk.dim(` Base URL: ${config.baseUrl}\n`));
17
+
18
+ console.log(chalk.green('\n✓ Logged in'));
19
+ console.log(chalk.dim(` Config saved to ~/.badgr/config.json`));
20
+
21
+ // Verify the key works against the live API
22
+ try {
23
+ await callApi('/models', { apiKey: config.apiKey, baseUrl: config.baseUrl });
24
+ console.log(chalk.green('✓ API reachable'));
25
+ console.log(chalk.green('✓ API key valid\n'));
26
+ } catch (err) {
27
+ if (err.httpStatus === 401 || err.httpStatus === 403) {
28
+ console.log(chalk.yellow('⚠ API key may be invalid — double-check and run badgr login again\n'));
29
+ } else {
30
+ console.log(chalk.yellow('⚠ Could not reach the API right now — check your internet connection\n'));
31
+ }
32
+ }
33
+
34
+ console.log(chalk.dim(` Run ${chalk.cyan('badgr run python train.py')} to launch your first job.\n`));
18
35
  return config;
19
36
  }
@@ -1,14 +1,13 @@
1
+ import readline from 'readline';
1
2
  import { requireApiKey } from '../config.js';
2
3
  import { callApi, terminateDeployment } from '../api.js';
3
4
  import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
4
5
  import { rankAlternatives, promptFallback } from '../fallback.js';
5
6
 
6
7
  /**
7
- * badgr run python train.py --gpu A100 # attached (default)
8
+ * badgr run python train.py # gpu=auto, attached
9
+ * badgr run python train.py --gpu A100 # specific GPU
8
10
  * badgr run --image my/image:latest --gpu L40S --detach
9
- *
10
- * Attaches by default: polls status + streams logs until the job finishes,
11
- * then exits with the job's exit code. Pass --detach to return immediately.
12
11
  */
13
12
  export function parseRunArgs(args) {
14
13
  const flags = {};
@@ -38,26 +37,15 @@ function fmtRuntime(ms) {
38
37
  return `${m}m ${s % 60}s`;
39
38
  }
40
39
 
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
40
+ const HEARTBEAT_WARN_POLLS = 3;
41
+ const HEARTBEAT_KILL_POLLS = 15;
44
42
 
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
43
  export function classifyFailure(finalStatus, exitCode) {
51
44
  if (finalStatus === 'failed' && (exitCode === null || exitCode === undefined)) return 'infrastructure';
52
45
  if (exitCode !== null && exitCode !== undefined && exitCode !== 0) return 'customer_code';
53
46
  return null;
54
47
  }
55
48
 
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
49
  async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost = null, ratePerHour = 0, onTeardown }) {
62
50
  const TERMINAL = new Set(['stopped', 'failed', 'completed']);
63
51
  const POLL_MS = 4000;
@@ -81,14 +69,12 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
81
69
  const elapsedMs = Date.now() - startMs;
82
70
  const spentSoFar = ratePerHour * (elapsedMs / 3_600_000);
83
71
 
84
- // Hard spend cap
85
72
  if (maxCost !== null && spentSoFar >= maxCost) {
86
73
  tearing = true;
87
74
  onTeardown('max-cost');
88
75
  return { status: 'timeout', exitCode: null, runtimeMs: elapsedMs, failureType: null };
89
76
  }
90
77
 
91
- // Hard runtime cap
92
78
  if (maxRuntimeMs !== null && elapsedMs >= maxRuntimeMs) {
93
79
  tearing = true;
94
80
  onTeardown('max-runtime');
@@ -107,7 +93,7 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
107
93
  if (lastStatus === 'running') {
108
94
  const lostSec = Math.round((consecutiveErrs * POLL_MS) / 1000);
109
95
  if (consecutiveErrs === HEARTBEAT_WARN_POLLS) {
110
- console.log(chalk.yellow(`\n ⚠ No heartbeat for ${lostSec}s — Vast machine may be unresponsive`));
96
+ console.log(chalk.yellow(`\n ⚠ No heartbeat for ${lostSec}s — cloud machine may be unresponsive`));
111
97
  } else if (consecutiveErrs >= HEARTBEAT_KILL_POLLS) {
112
98
  tearing = true;
113
99
  onTeardown('heartbeat-lost');
@@ -155,41 +141,103 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
155
141
  }
156
142
  }
157
143
 
144
+ // Ask the backend for the cheapest available GPU right now.
145
+ // Returns { gpu, region, price } or null when nothing is available.
146
+ async function findAutoGpu(config, chalk) {
147
+ try {
148
+ const params = new URLSearchParams({ max_price: '10' });
149
+ return await callApi(`/capacity/auto?${params}`, {
150
+ apiKey: config.apiKey,
151
+ baseUrl: config.baseUrl,
152
+ });
153
+ } catch (err) {
154
+ if (err.errorData?.code === 'NO_CAPACITY_MATCH') return null;
155
+ throw err;
156
+ }
157
+ }
158
+
159
+ function askConfirm(prompt) {
160
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
161
+ return new Promise(resolve => rl.question(prompt, ans => { rl.close(); resolve(ans.trim()); }));
162
+ }
163
+
158
164
  export async function runCommand(config, args, chalk) {
159
165
  const { flags, positional } = parseRunArgs(args);
160
166
 
161
167
  if (positional.length === 0 && !flags.image) {
162
- console.error(chalk.red('Usage: badgr run <command...> --gpu <type>'));
163
- console.error(chalk.red(' badgr run --image my/image:latest --gpu A100'));
168
+ console.error(chalk.red('Usage: badgr run <command...>'));
169
+ console.error(chalk.red(' badgr run --image my/image:latest'));
164
170
  return;
165
171
  }
166
172
 
167
173
  requireApiKey(config);
168
174
 
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;
176
-
177
- console.log(chalk.bold('\n⚡ Running GPU job\n'));
178
- if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
179
- if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
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`);
184
- if (detach) console.log(` ${chalk.dim('(detached — returns immediately)')}`);
185
- console.log();
175
+ const command = positional.length > 0 ? positional : undefined;
176
+ const image = flags.image || (command ? 'python:3.11-slim' : undefined);
177
+ const detach = flags.detach || false;
178
+ const fallbackMode = flags.noFallback ? 'none' : (flags.fallback || 'closest');
179
+ const maxRuntimeMs = flags.maxRuntime ? flags.maxRuntime * 60 * 1000 : null;
180
+ const maxCost = flags.maxCost ?? null;
181
+
182
+ // ── Auto GPU selection (no --gpu specified) ────────────────────────────────
183
+ let gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : null;
184
+ let autoRegion = null;
185
+
186
+ if (!gpu) {
187
+ console.log(chalk.bold('\n⚡ Running GPU job\n'));
188
+ if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
189
+ if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
190
+ console.log();
191
+ process.stdout.write(chalk.dim(' Finding GPU...'));
192
+
193
+ let best;
194
+ try {
195
+ best = await findAutoGpu(config, chalk);
196
+ } catch (err) {
197
+ process.stdout.write('\n');
198
+ console.error(chalk.red(`\n ✗ Could not find GPU capacity: ${err.message}\n`));
199
+ process.exit(1);
200
+ }
186
201
 
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'));
202
+ process.stdout.write('\n');
203
+
204
+ if (!best) {
205
+ console.error(chalk.red('\n ✗ No GPU capacity available right now.\n'));
206
+ console.error(chalk.dim(' Run `badgr capacity` for details, or try again in a few minutes.'));
207
+ process.exit(1);
208
+ }
209
+
210
+ console.log(`\n ${chalk.bold('Best match:')} ${chalk.cyan(best.gpu)} in ${best.region} — ${chalk.green('$' + best.price.toFixed(2) + '/hr')} estimated`);
211
+ console.log();
212
+
213
+ if (process.stdin.isTTY) {
214
+ const answer = await askConfirm(` Press ${chalk.bold('Enter')} to run, or ${chalk.bold('q')} to cancel: `);
215
+ if (answer.toLowerCase() === 'q') {
216
+ console.log(chalk.dim('\n Cancelled.\n'));
217
+ process.exit(0);
218
+ }
219
+ } else {
220
+ console.log(chalk.dim(` Auto-selecting ${best.gpu} (non-interactive).`));
221
+ }
222
+
223
+ gpu = best.gpu;
224
+ autoRegion = best.region;
225
+ console.log();
226
+ } else {
227
+ // Specific GPU requested — show header
228
+ console.log(chalk.bold('\n⚡ Running GPU job\n'));
229
+ if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
230
+ if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
231
+ console.log(` ${chalk.bold('GPU:')} ${gpu}`);
232
+ if (flags.maxRuntime) console.log(` ${chalk.bold('Max runtime:')} ${flags.maxRuntime}min`);
233
+ if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
234
+ if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice.toFixed(2)}/hr`);
235
+ if (detach) console.log(` ${chalk.dim('(detached — returns immediately)')}`);
236
+ console.log();
237
+
238
+ if (!detach && !flags.maxRuntime && !maxCost) {
239
+ console.log(chalk.dim(' Tip: add --max-runtime 60 or --max-cost 5.00 to cap spend automatically'));
240
+ }
193
241
  }
194
242
 
195
243
  console.log(chalk.dim(' Finding best available GPU capacity...'));
@@ -198,11 +246,13 @@ export async function runCommand(config, args, chalk) {
198
246
  }
199
247
 
200
248
  function buildBody(gpuOverride, regionOverride) {
201
- const effectiveRegion = regionOverride ?? (flags.region ? flags.region.toUpperCase() : undefined);
249
+ const effectiveRegion = regionOverride
250
+ ?? autoRegion
251
+ ?? (flags.region ? flags.region.toUpperCase() : undefined);
202
252
  return {
203
253
  command,
204
254
  image,
205
- gpu: (gpuOverride || gpu).toUpperCase().replace('-', '_'),
255
+ gpu: (gpuOverride || gpu),
206
256
  gpu_count: flags.count || 1,
207
257
  ...(effectiveRegion ? { region: effectiveRegion } : {}),
208
258
  max_price_per_hour: flags.maxPrice,
@@ -227,15 +277,14 @@ export async function runCommand(config, args, chalk) {
227
277
  process.exit(1);
228
278
  }
229
279
 
230
- // Merge same-GPU-other-regions and cross-GPU alternatives into one ranked list.
231
280
  const pool = [
232
281
  ...(Array.isArray(d.same_gpu_other_regions) ? d.same_gpu_other_regions : []),
233
282
  ...(Array.isArray(d.alternatives) ? d.alternatives : []),
234
283
  ];
235
284
 
236
285
  if (pool.length === 0) {
237
- console.error(chalk.red(`\n ✗ ${gpu} isn't available right now and no alternatives found.\n`));
238
- console.error(chalk.dim(' Try increasing --max-price or contact support for a manual quote.'));
286
+ console.error(chalk.red(`\n ✗ ${gpu} isn't available right now and no alternatives were found.\n`));
287
+ console.error(chalk.dim(' Try `badgr capacity` to see what\'s available, or try again shortly.'));
239
288
  process.exit(1);
240
289
  }
241
290
 
@@ -247,7 +296,7 @@ export async function runCommand(config, args, chalk) {
247
296
  process.exit(0);
248
297
  }
249
298
 
250
- console.log(chalk.dim(`\n Running on ${chosen.gpu} in ${chosen.region}...\n`));
299
+ console.log(chalk.dim(`\n Trying ${chosen.gpu} in ${chosen.region}...\n`));
251
300
 
252
301
  try {
253
302
  dep = await callApi('/run', {
@@ -259,19 +308,21 @@ export async function runCommand(config, args, chalk) {
259
308
  } catch (err2) {
260
309
  const d2 = err2.errorData;
261
310
  if (d2?.code === 'PROVISIONING_FAILED' || d2?.code === 'PROVIDER_ADAPTER_ERROR') {
262
- console.error(chalk.red(`\n ✗ Badgr found ${chosen.gpu} capacity, but the provider adapter failed during provisioning.`));
263
- if (d2.message) console.error(chalk.dim(` Detail: ${d2.message}`));
311
+ console.error(chalk.red(`\n ✗ Badgr found ${chosen.gpu} capacity but could not start the machine. Please try again.\n`));
264
312
  } else {
265
- console.error(chalk.red(`\n ✗ Job failed to start on ${chosen.gpu}: ${err2.message}`));
313
+ console.error(chalk.red(`\n ✗ Job failed to start on ${chosen.gpu}: ${err2.message}\n`));
266
314
  }
267
- console.error(chalk.dim(`\n Debug: BADGR_DEBUG=1 badgr run … shows full request/response\n`));
315
+ console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.\n`));
268
316
  process.exit(1);
269
317
  }
318
+ } else if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
319
+ console.error(chalk.red(`\n ✗ Badgr found ${gpu} capacity but could not start the machine. Please try again.\n`));
320
+ console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.\n`));
321
+ process.exit(1);
270
322
  } else {
271
- console.error(chalk.red(`\n ✗ Job failed to start: ${err.message}`));
272
- console.error(chalk.dim(`\n Debug: BADGR_DEBUG=1 badgr run … shows full request/response`));
273
- console.error(chalk.dim(` Config: badgr config`));
274
- console.error(chalk.dim(` Docs: badgr --help\n`));
323
+ console.error(chalk.red(`\n ✗ Could not start job: ${err.message}\n`));
324
+ console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr run … for a full trace.`));
325
+ console.error(chalk.dim(` Check config: badgr config\n`));
275
326
  process.exit(1);
276
327
  }
277
328
  }
@@ -281,13 +332,13 @@ export async function runCommand(config, args, chalk) {
281
332
  receiptId: rcptId,
282
333
  action: 'badgr run',
283
334
  deploymentId: dep.deployment_id,
284
- provider: dep.provider,
285
335
  gpu: dep.gpu_type,
286
336
  status: dep.status,
287
337
  createdAt: new Date().toISOString(),
288
338
  });
289
339
 
290
- console.log(chalk.bold(`\n Job ID: ${chalk.cyan(dep.deployment_id)}`));
340
+ console.log();
341
+ console.log(chalk.bold(` Job ID: ${chalk.cyan(dep.deployment_id)}`));
291
342
  console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
292
343
  if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
293
344
  console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
@@ -298,16 +349,14 @@ export async function runCommand(config, args, chalk) {
298
349
  return;
299
350
  }
300
351
 
301
- // ── Attached mode: stream logs until job completes ────────────────────────
302
352
  const ratePerHour = dep.cost_per_hour || 0;
303
- console.log(chalk.dim('\n ── Attaching (Ctrl+C tears down job) ─────────────────────────\n'));
353
+ console.log(chalk.dim('\n ── Streaming logs (Ctrl+C to stop job) ─────────────────────────\n'));
304
354
 
305
- // Teardown helper — called on SIGINT, max-runtime, max-cost, or heartbeat loss.
306
355
  async function teardown(reason) {
307
356
  const labels = {
308
357
  'max-runtime': chalk.yellow('\n ⏱ Max runtime reached — stopping job...'),
309
358
  'max-cost': chalk.yellow('\n 💰 Spend cap reached — stopping job...'),
310
- 'heartbeat-lost': chalk.red('\n ✗ Heartbeat lost infrastructure failure, stopping job...'),
359
+ 'heartbeat-lost': chalk.red('\n ✗ No response from machine stopping job...'),
311
360
  'interrupted': chalk.yellow('\n Stopping job...'),
312
361
  };
313
362
  console.log(labels[reason] ?? chalk.yellow('\n Stopping job...'));
@@ -318,8 +367,7 @@ export async function runCommand(config, args, chalk) {
318
367
  }
319
368
  const runtimeMs = Date.now() - attachStart;
320
369
  const finalCost = ratePerHour * (runtimeMs / 3_600_000);
321
- const failureType = reason === 'heartbeat-lost' ? 'infrastructure' : null;
322
- updateReceipt(rcptId, { status: reason, runtimeSeconds: Math.round(runtimeMs / 1000), finalCost, failureType });
370
+ updateReceipt(rcptId, { status: reason, runtimeSeconds: Math.round(runtimeMs / 1000), finalCost });
323
371
  console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Est. cost: $${finalCost.toFixed(4)}`));
324
372
  console.log(chalk.dim(' Job stopped. Billing ended.\n'));
325
373
  process.exit(reason === 'interrupted' ? 0 : 1);
@@ -345,7 +393,6 @@ export async function runCommand(config, args, chalk) {
345
393
  failureType,
346
394
  });
347
395
 
348
- // ── Job summary ──────────────────────────────────────────────────────────
349
396
  console.log(` ${chalk.bold('Runtime:')} ${fmtRuntime(runtimeMs)}`);
350
397
  if (ratePerHour > 0) {
351
398
  console.log(` ${chalk.bold('Cost:')} $${finalCost.toFixed(4)} (${chalk.dim(`$${ratePerHour.toFixed(2)}/hr`)})`);
@@ -353,18 +400,19 @@ export async function runCommand(config, args, chalk) {
353
400
  if (exitCode !== null && exitCode !== undefined) {
354
401
  console.log(` ${chalk.bold('Exit code:')} ${exitCode !== 0 ? chalk.red(exitCode) : chalk.green(exitCode)}`);
355
402
  }
403
+ console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
356
404
 
357
405
  if (finalStatus === 'failed' || (exitCode !== null && exitCode !== 0)) {
358
- const label = failureType === 'infrastructure'
359
- ? chalk.red(`\n ✗ Infrastructure failure (${dep.deployment_id}) — not your code\n`)
360
- : chalk.red(`\n ✗ Job failed (${dep.deployment_id})\n`);
361
- console.error(label);
362
406
  if (failureType === 'infrastructure') {
363
- console.error(chalk.dim(' This is a provider-side failure. Contact support with your receipt ID.'));
407
+ console.error(chalk.red(`\n Machine failure this is not your code.\n`));
408
+ console.error(chalk.dim(' Contact support with your receipt ID for a refund.'));
409
+ } else {
410
+ console.error(chalk.red(`\n ✗ Job failed (exit ${exitCode ?? 'unknown'})\n`));
411
+ console.error(chalk.dim(` Check logs: badgr logs ${dep.deployment_id}`));
364
412
  }
365
- console.error(chalk.dim(` Logs: badgr logs ${dep.deployment_id}\n`));
413
+ console.log();
366
414
  process.exit(exitCode ?? 1);
367
415
  } else {
368
- console.log(chalk.green(`\n ✓ Job complete\n`));
416
+ console.log(chalk.green(`\n ✓ Complete\n`));
369
417
  }
370
418
  }
@@ -4,11 +4,10 @@ import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../
4
4
  import { rankAlternatives, promptFallback } from '../fallback.js';
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
8
9
  *
9
- * Provisions a persistent vLLM endpoint, health-checks it before printing
10
- * "Endpoint ready", and returns an OpenAI-compatible base URL.
11
- * Stop billing with `badgr down <id>`.
10
+ * GPU defaults to "AUTO" backend infers from model size.
12
11
  */
13
12
  export function parseServeArgs(args) {
14
13
  const flags = {};
@@ -27,24 +26,21 @@ export function parseServeArgs(args) {
27
26
  return { model, flags };
28
27
  }
29
28
 
30
- // Poll GET <endpointUrl>/models until it returns 200 or timeout expires.
31
- // Returns true if healthy, false if timed out.
32
29
  async function waitForEndpoint(endpointUrl, timeoutMs = 5 * 60 * 1000, chalk) {
33
30
  const deadline = Date.now() + timeoutMs;
34
- const modelsUrl = `${endpointUrl}/models`;
35
31
  let attempt = 0;
36
32
 
37
33
  while (Date.now() < deadline) {
38
34
  attempt++;
39
35
  try {
40
- const res = await fetch(modelsUrl, { signal: AbortSignal.timeout(8000) });
36
+ const res = await fetch(`${endpointUrl}/models`, { signal: AbortSignal.timeout(8000) });
41
37
  if (res.ok) return true;
42
38
  } catch {
43
- // network not up yet — keep polling
39
+ // still starting
44
40
  }
45
41
  const elapsed = Math.round((Date.now() - (deadline - timeoutMs)) / 1000);
46
42
  process.stdout.write(
47
- `\r ${chalk.dim(`Waiting for endpoint… ${elapsed}s (attempt ${attempt})`)} `
43
+ `\r ${chalk.dim(`Health-checking /v1/models… ${elapsed}s`)} `
48
44
  );
49
45
  await new Promise(r => setTimeout(r, 8000));
50
46
  }
@@ -56,40 +52,43 @@ export async function serveCommand(config, args, chalk) {
56
52
  const { model, flags } = parseServeArgs(args);
57
53
 
58
54
  if (!model) {
59
- console.error(chalk.red('Usage: badgr serve <model> --gpu <type>'));
60
- console.error(chalk.red(' badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S'));
55
+ console.error(chalk.red('Usage: badgr serve <model>'));
56
+ console.error(chalk.red(' badgr serve meta-llama/Llama-3.1-8B-Instruct'));
61
57
  return;
62
58
  }
63
59
 
64
60
  requireApiKey(config);
65
61
 
66
- const gpu = flags.gpu || 'L40S';
62
+ // gpu=AUTO tells the backend to infer the right GPU from model size
63
+ const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : 'AUTO';
64
+ const gpuLabel = gpu === 'AUTO' ? 'auto' : gpu;
67
65
 
68
- console.log(chalk.bold('\n🚀 Serving model\n'));
66
+ console.log(chalk.bold('\nServing model\n'));
69
67
  console.log(` ${chalk.bold('Model:')} ${model}`);
70
- console.log(` ${chalk.bold('GPU:')} ${gpu}`);
68
+ console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
71
69
  console.log();
72
- console.log(chalk.dim(' Finding best available GPU capacity...'));
73
- if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
74
- console.log(chalk.dim(` API: ${config.baseUrl}`));
75
- }
70
+ process.stdout.write(chalk.dim(' Finding GPU capacity...\n'));
76
71
 
77
72
  const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
78
73
 
74
+ function buildBody(gpuOverride, regionOverride) {
75
+ return {
76
+ model,
77
+ gpu: gpuOverride || gpu,
78
+ gpu_count: flags.count || 1,
79
+ ...(regionOverride || effectiveRegion ? { region: regionOverride || effectiveRegion } : {}),
80
+ max_price_per_hour: flags.maxPrice,
81
+ name: flags.name,
82
+ };
83
+ }
84
+
79
85
  let dep;
80
86
  try {
81
87
  dep = await callApi('/serve', {
82
88
  method: 'POST',
83
89
  apiKey: config.apiKey,
84
90
  baseUrl: config.baseUrl,
85
- body: {
86
- model,
87
- gpu: gpu.toUpperCase().replace('-', '_'),
88
- gpu_count: flags.count || 1,
89
- ...(effectiveRegion ? { region: effectiveRegion } : {}),
90
- max_price_per_hour: flags.maxPrice,
91
- name: flags.name,
92
- },
91
+ body: buildBody(),
93
92
  });
94
93
  } catch (err) {
95
94
  const d = err.errorData;
@@ -100,68 +99,60 @@ export async function serveCommand(config, args, chalk) {
100
99
  ];
101
100
 
102
101
  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.'));
102
+ console.error(chalk.red(`\n ✗ No GPU capacity available right now.\n`));
103
+ console.error(chalk.dim(' Run `badgr capacity` to see alternatives, or try again shortly.'));
105
104
  process.exit(1);
106
105
  }
107
106
 
108
- const ranked = rankAlternatives(gpu, pool, 'closest');
109
- const chosen = await promptFallback(gpu, ranked, chalk);
107
+ const ranked = rankAlternatives(gpuLabel, pool, 'closest');
108
+ const chosen = await promptFallback(gpuLabel, ranked, chalk);
110
109
 
111
110
  if (!chosen) {
112
111
  console.log(chalk.dim('\n Cancelled.\n'));
113
112
  process.exit(0);
114
113
  }
115
114
 
116
- console.log(chalk.dim(`\n Serving on ${chosen.gpu} in ${chosen.region}...\n`));
115
+ console.log(chalk.dim(`\n Trying ${chosen.gpu} in ${chosen.region}...\n`));
117
116
 
118
117
  try {
119
118
  dep = await callApi('/serve', {
120
119
  method: 'POST',
121
120
  apiKey: config.apiKey,
122
121
  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
- },
122
+ body: buildBody(chosen.gpu, chosen.region),
131
123
  });
132
124
  } catch (err2) {
133
125
  const d2 = err2.errorData;
134
126
  if (d2?.code === 'PROVISIONING_FAILED' || d2?.code === 'PROVIDER_ADAPTER_ERROR') {
135
- console.error(chalk.red(`\n ✗ Badgr found ${chosen.gpu} capacity, but the provider adapter failed during provisioning.`));
136
- if (d2.message) console.error(chalk.dim(` Detail: ${d2.message}`));
127
+ console.error(chalk.red(`\n ✗ Badgr found ${chosen.gpu} capacity but could not start the endpoint. Please try again.\n`));
137
128
  } else {
138
- console.error(chalk.red(`\n ✗ Serve failed on ${chosen.gpu}: ${err2.message}`));
129
+ console.error(chalk.red(`\n ✗ Failed to start endpoint on ${chosen.gpu}: ${err2.message}\n`));
139
130
  }
140
- console.error(chalk.dim(`\n Debug: BADGR_DEBUG=1 badgr serve … shows full request/response\n`));
131
+ console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
141
132
  process.exit(1);
142
133
  }
134
+ } else if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
135
+ console.error(chalk.red(`\n ✗ Badgr found capacity but could not start the endpoint. Please try again.\n`));
136
+ console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
137
+ process.exit(1);
143
138
  } else {
144
- // Write a failure receipt so every serve attempt is auditable.
145
139
  const failRcptId = generateReceiptId();
146
140
  addReceipt({
147
141
  receiptId: failRcptId,
148
142
  action: 'badgr serve',
149
143
  model,
150
- gpu,
144
+ gpu: gpuLabel,
151
145
  status: 'failed',
152
146
  failureType: 'infrastructure',
153
147
  createdAt: new Date().toISOString(),
154
148
  });
155
- console.error(chalk.red(`\n ✗ Serve failed: ${err.message}`));
149
+ console.error(chalk.red(`\n ✗ Could not start endpoint: ${err.message}`));
156
150
  console.error(chalk.dim(`\n Receipt: ${failRcptId}`));
157
- console.error(chalk.dim(` Debug: BADGR_DEBUG=1 badgr serve … shows full request/response`));
158
- console.error(chalk.dim(` Config: badgr config`));
159
- console.error(chalk.dim(` Docs: badgr --help\n`));
151
+ console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
160
152
  return;
161
153
  }
162
154
  }
163
155
 
164
- // Mirror to local store so badgr down/status/receipts work offline
165
156
  addDeployment({
166
157
  id: dep.deployment_id,
167
158
  name: dep.name,
@@ -169,7 +160,6 @@ export async function serveCommand(config, args, chalk) {
169
160
  model: dep.model || model,
170
161
  gpu: dep.gpu_type,
171
162
  count: dep.gpu_count,
172
- provider: dep.provider,
173
163
  status: dep.status,
174
164
  endpointUrl: dep.endpoint_url || dep.openai_base_url,
175
165
  receiptId: dep.receipt_id,
@@ -182,7 +172,6 @@ export async function serveCommand(config, args, chalk) {
182
172
  receiptId: rcptId,
183
173
  action: 'badgr serve',
184
174
  deploymentId: dep.deployment_id,
185
- provider: dep.provider,
186
175
  gpu: dep.gpu_type,
187
176
  status: dep.status,
188
177
  createdAt: new Date().toISOString(),
@@ -190,48 +179,41 @@ export async function serveCommand(config, args, chalk) {
190
179
 
191
180
  const endpointUrl = dep.endpoint_url || dep.openai_base_url || config.baseUrl;
192
181
 
193
- // ── Health check before declaring "ready" ─────────────────────────────────
182
+ // ── Health check ──────────────────────────────────────────────────────────
194
183
  let endpointReady = false;
195
-
196
184
  if (flags.noWait) {
197
- console.log(chalk.yellow('\nSkipped health check (--no-wait)\n'));
185
+ console.log(chalk.yellow('\n Skipped health check (--no-wait)\n'));
198
186
  } else {
199
- console.log(chalk.dim('\n Health-checking endpoint (up to 5 min)...'));
200
187
  endpointReady = await waitForEndpoint(endpointUrl, 5 * 60 * 1000, chalk);
201
188
  process.stdout.write('\n');
202
- if (!endpointReady) {
203
- updateReceipt(rcptId, { status: 'health_check_timeout' });
204
- }
189
+ if (!endpointReady) updateReceipt(rcptId, { status: 'health_check_timeout' });
205
190
  }
206
191
 
207
- // ── Print result ──────────────────────────────────────────────────────────
192
+ // ── Result ────────────────────────────────────────────────────────────────
208
193
  if (endpointReady) {
209
- console.log(chalk.green('✓ Endpoint ready\n'));
194
+ console.log(chalk.green('\n✓ Endpoint ready\n'));
210
195
  } else {
211
- console.log(chalk.yellow('⏳ Endpoint still starting (health check timed out)\n'));
212
- console.log(chalk.dim(' The deployment was provisioned but did not respond within 5 minutes.'));
213
- console.log(chalk.dim(' It may still be loading the model. Check:'));
214
- console.log(chalk.dim(` badgr status`));
215
- console.log(chalk.dim(` badgr logs ${dep.deployment_id}`));
216
- console.log(chalk.dim(` curl ${endpointUrl}/models`));
217
- console.log(chalk.dim(` If it never starts, run: badgr down ${dep.deployment_id}`));
196
+ console.log(chalk.yellow('\n⏳ Endpoint still starting\n'));
197
+ console.log(chalk.dim(' The deployment is provisioned but not yet responding.'));
198
+ console.log(chalk.dim(` Check: badgr logs ${dep.deployment_id}`));
199
+ console.log(chalk.dim(` Stop if it never starts: badgr down ${dep.deployment_id}`));
218
200
  console.log();
219
201
  }
220
202
 
221
- console.log(` ${chalk.bold('Deployment:')} ${chalk.cyan(dep.deployment_id)}`);
203
+ console.log(` ${chalk.bold('Base URL:')} ${chalk.cyan(endpointUrl)}`);
222
204
  console.log(` ${chalk.bold('Model:')} ${dep.model || model}`);
223
205
  console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
224
- console.log(` ${chalk.bold('Endpoint:')} ${chalk.cyan(endpointUrl)}`);
225
206
  if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
226
207
  console.log(` ${chalk.bold('Logs:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
227
- console.log(`\n ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
208
+ console.log(` ${chalk.bold('Stop billing:')} ${chalk.dim(`badgr down ${dep.deployment_id}`)}`);
209
+ console.log();
228
210
 
229
211
  if (endpointReady) {
230
- console.log(`\n ${chalk.bold('Use with OpenAI SDK:')}`);
212
+ const keySnip = config.apiKey?.slice(0, 8) || 'sk-...';
213
+ console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
231
214
  console.log(chalk.dim(` from openai import OpenAI`));
232
- console.log(chalk.dim(` client = OpenAI(api_key="${config.apiKey?.slice(0, 8) || 'sk-...'}...", base_url="${endpointUrl}")`));
215
+ console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${keySnip}...")`));
233
216
  console.log(chalk.dim(` resp = client.chat.completions.create(model="${dep.model || model}", messages=[...])`));
217
+ console.log();
234
218
  }
235
-
236
- console.log(`\n ${chalk.dim(`Stop billing: badgr down ${dep.deployment_id}`)}\n`);
237
219
  }
@@ -2,24 +2,20 @@ import { listDeployments as localDeployments } from '../store.js';
2
2
  import { listDeployments as apiDeployments } from '../api.js';
3
3
 
4
4
  export async function statusCommand(config, args, chalk) {
5
- console.log(chalk.bold('\n📊 GPU Status\n'));
6
-
7
5
  let deployments = [];
8
6
 
9
- // ── Live data from the backend ────────────────────────────────────────────
10
7
  if (config.apiKey) {
11
8
  try {
12
9
  const data = await apiDeployments(config);
13
10
  deployments = data?.deployments ?? [];
14
11
  } catch (err) {
15
- console.log(chalk.yellow(` Could not reach API: ${err.message}. Showing local state.\n`));
12
+ console.log(chalk.yellow(`\n Could not reach API: ${err.message}. Showing local state.\n`));
16
13
  deployments = localDeployments().map(d => ({
17
14
  deployment_id: d.id,
18
15
  name: d.name,
19
16
  workload_type: d.type,
20
17
  gpu_type: d.gpu,
21
18
  gpu_count: d.count,
22
- provider: d.provider,
23
19
  status: d.status,
24
20
  cost_per_hour: d.costPerHour,
25
21
  endpoint_url: d.endpointUrl,
@@ -33,7 +29,6 @@ export async function statusCommand(config, args, chalk) {
33
29
  workload_type: d.type,
34
30
  gpu_type: d.gpu,
35
31
  gpu_count: d.count,
36
- provider: d.provider,
37
32
  status: d.status,
38
33
  cost_per_hour: d.costPerHour,
39
34
  endpoint_url: d.endpointUrl,
@@ -41,54 +36,46 @@ export async function statusCommand(config, args, chalk) {
41
36
  }));
42
37
  }
43
38
 
44
- if (deployments.length === 0) {
45
- console.log(chalk.dim(' No active deployments.'));
46
- console.log(chalk.dim(' Run `badgr serve <model> --gpu L40S` to provision one.\n'));
39
+ const running = deployments.filter(d => d.status === 'running' || d.status === 'provisioning');
40
+
41
+ if (running.length === 0) {
42
+ console.log(chalk.dim('\n Nothing running.\n'));
43
+ console.log(chalk.dim(' badgr run python train.py'));
44
+ console.log(chalk.dim(' badgr serve meta-llama/Llama-3.1-8B-Instruct\n'));
47
45
  return;
48
46
  }
49
47
 
50
- const cols = { name: 16, type: 9, gpu: 11, status: 14, price: 9 };
48
+ console.log(chalk.bold('\nRunning now:\n'));
49
+ for (const d of running) {
50
+ const id = d.name || d.deployment_id;
51
+ const type = d.workload_type === 'endpoint' ? 'endpoint' : 'job';
52
+ const gpu = d.gpu_type || '—';
53
+ const rate = d.cost_per_hour > 0 ? chalk.yellow(`$${d.cost_per_hour.toFixed(2)}/hr`) : '';
54
+ const badge = d.status === 'provisioning'
55
+ ? chalk.yellow('● starting')
56
+ : chalk.green('● running');
51
57
 
52
- const header =
53
- 'Name'.padEnd(cols.name) +
54
- 'Type'.padEnd(cols.type) +
55
- 'GPU'.padEnd(cols.gpu) +
56
- 'Status'.padEnd(cols.status) +
57
- 'Price/hr';
58
- console.log(' ' + chalk.bold(header));
59
- console.log(' ' + '─'.repeat(Object.values(cols).reduce((a, b) => a + b, 0) + 8));
58
+ console.log(` ${badge} ${chalk.bold(id)} ${type} ${gpu} ${rate}`);
60
59
 
61
- deployments.forEach(d => {
62
- const statusColor = d.status === 'running'
63
- ? chalk.green(d.status.padEnd(cols.status))
64
- : d.status === 'provisioning'
65
- ? chalk.yellow(d.status.padEnd(cols.status))
66
- : chalk.dim(d.status.padEnd(cols.status));
67
- const price = d.cost_per_hour > 0 ? `$${d.cost_per_hour.toFixed(2)}` : '—';
68
- const name = (d.name || d.deployment_id || '').slice(0, cols.name - 1);
69
- const gpu = (d.gpu_type || '—').slice(0, cols.gpu - 1).padEnd(cols.gpu);
70
- console.log(
71
- ' ' +
72
- name.padEnd(cols.name) +
73
- (d.workload_type || '—').padEnd(cols.type) +
74
- gpu +
75
- statusColor +
76
- price
77
- );
78
- });
60
+ if (d.workload_type === 'endpoint' && d.endpoint_url) {
61
+ console.log(` ${chalk.dim('URL:')} ${d.endpoint_url}`);
62
+ }
63
+ if (d.model) {
64
+ console.log(` ${chalk.dim('Model:')} ${d.model}`);
65
+ }
66
+ console.log();
67
+ }
79
68
 
80
- console.log();
69
+ const billable = running.filter(d => (d.cost_per_hour || 0) > 0);
70
+ if (billable.length > 0) {
71
+ const totalPerHr = billable.reduce((s, d) => s + (d.cost_per_hour || 0), 0);
72
+ console.log(` ${chalk.bold('Total billing:')} ${chalk.yellow(`$${totalPerHr.toFixed(2)}/hr`)}\n`);
73
+ }
81
74
 
82
- // Show endpoint URLs for running endpoint deployments
83
- const endpoints = deployments.filter(d => d.workload_type === 'endpoint' && d.status === 'running');
84
- if (endpoints.length > 0) {
85
- console.log(chalk.bold(' Endpoints\n'));
86
- endpoints.forEach(d => {
87
- const url = d.endpoint_url || config.baseUrl;
88
- console.log(` ${chalk.cyan(d.name || d.deployment_id)}`);
89
- console.log(` ${chalk.dim('URL:')} ${url}`);
90
- if (d.model) console.log(` ${chalk.dim('model:')} ${d.model}`);
91
- });
92
- console.log();
75
+ console.log(chalk.bold('Stop billing:\n'));
76
+ for (const d of running) {
77
+ const id = d.name || d.deployment_id;
78
+ console.log(` ${chalk.cyan(`badgr down ${id}`)}`);
93
79
  }
80
+ console.log();
94
81
  }