badgr-cli 1.0.0
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/HOW_IT_WORKS.md +245 -0
- package/README.md +147 -0
- package/badgr-cli-1.0.0.tgz +0 -0
- package/package.json +26 -0
- package/src/api.js +120 -0
- package/src/badgr.js +100 -0
- package/src/commands/deploy.js +47 -0
- package/src/commands/down.js +55 -0
- package/src/commands/login.js +20 -0
- package/src/commands/logs.js +49 -0
- package/src/commands/models.js +39 -0
- package/src/commands/receipts.js +82 -0
- package/src/commands/run.js +162 -0
- package/src/commands/serve.js +160 -0
- package/src/commands/shell.js +21 -0
- package/src/commands/status.js +97 -0
- package/src/commands/up.js +134 -0
- package/src/config.js +33 -0
- package/src/router.js +104 -0
- package/src/spec.js +92 -0
- package/src/store.js +88 -0
- package/tests/api.test.js +140 -0
- package/tests/commands.test.js +81 -0
- package/tests/config.test.js +73 -0
- package/tests/router.test.js +157 -0
- package/tests/spec.test.js +143 -0
- package/tests/store.test.js +126 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { requireApiKey } from '../config.js';
|
|
2
|
+
import { findDeployment, removeDeployment, addReceipt, generateReceiptId } from '../store.js';
|
|
3
|
+
import { terminateDeployment } from '../api.js';
|
|
4
|
+
|
|
5
|
+
export async function downCommand(config, args, chalk) {
|
|
6
|
+
const idOrName = args.find(a => !a.startsWith('--'));
|
|
7
|
+
|
|
8
|
+
if (!idOrName) {
|
|
9
|
+
console.error(chalk.red('Usage: badgr down <deployment-id|name>'));
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
requireApiKey(config);
|
|
14
|
+
|
|
15
|
+
const localDep = findDeployment(idOrName);
|
|
16
|
+
|
|
17
|
+
// Resolve the deployment ID to send to the backend
|
|
18
|
+
const deploymentId = localDep?.id ?? idOrName;
|
|
19
|
+
|
|
20
|
+
console.log(chalk.dim(` Terminating ${deploymentId}...`));
|
|
21
|
+
|
|
22
|
+
let dep;
|
|
23
|
+
try {
|
|
24
|
+
dep = await terminateDeployment(config, deploymentId);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.error(chalk.red(`\n ā Terminate failed: ${err.message}\n`));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const rcptId = generateReceiptId();
|
|
31
|
+
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(),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Remove from local store
|
|
44
|
+
removeDeployment(idOrName);
|
|
45
|
+
|
|
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('Provider:')} ${dep.provider}`);
|
|
49
|
+
console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} Ć ${dep.gpu_count}`);
|
|
50
|
+
if (dep.started_at) {
|
|
51
|
+
const uptime = Math.round((Date.now() / 1000 - dep.started_at) / 60);
|
|
52
|
+
console.log(` ${chalk.bold('Uptime:')} ${uptime} min`);
|
|
53
|
+
}
|
|
54
|
+
console.log(`\n ${chalk.bold('Receipt ID:')} ${chalk.dim(rcptId)}\n`);
|
|
55
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { input } from '@inquirer/prompts';
|
|
2
|
+
|
|
3
|
+
export async function loginCommand(chalk, saveConfigFn) {
|
|
4
|
+
console.log(chalk.bold('\nš GPU.AI Login\n'));
|
|
5
|
+
|
|
6
|
+
const apiKey = await input({
|
|
7
|
+
message: 'Enter your GPU.AI API key:',
|
|
8
|
+
validate: v => v.trim() ? true : 'API key is required',
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const baseUrl = await input({
|
|
12
|
+
message: 'API base URL:',
|
|
13
|
+
default: 'https://api.gpu.ai/v1',
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const config = saveConfigFn({ apiKey: apiKey.trim(), baseUrl: baseUrl.trim() });
|
|
17
|
+
console.log(chalk.green('\nā Logged in ā config saved to ~/.gpu/config.json\n'));
|
|
18
|
+
console.log(chalk.dim(` Base URL: ${config.baseUrl}\n`));
|
|
19
|
+
return config;
|
|
20
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { findDeployment, listDeployments } from '../store.js';
|
|
2
|
+
import { requireApiKey } from '../config.js';
|
|
3
|
+
import { getDeploymentLogs } from '../api.js';
|
|
4
|
+
|
|
5
|
+
export async function logsCommand(config, args, chalk) {
|
|
6
|
+
const idOrName = args.find(a => !a.startsWith('--'));
|
|
7
|
+
const follow = args.includes('--follow') || args.includes('-f');
|
|
8
|
+
|
|
9
|
+
requireApiKey(config);
|
|
10
|
+
|
|
11
|
+
if (!idOrName) {
|
|
12
|
+
const deps = listDeployments();
|
|
13
|
+
if (deps.length === 0) {
|
|
14
|
+
console.log(chalk.dim('\n No active deployments. Run `badgr status` first.\n'));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
console.log(chalk.bold('\nš Active deployments:\n'));
|
|
18
|
+
deps.forEach(d => console.log(` ${chalk.cyan(d.name)} (${d.id})`));
|
|
19
|
+
console.log(chalk.dim('\n Usage: badgr logs <name|id> [--follow]\n'));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Resolve local name ā deployment ID
|
|
24
|
+
const localDep = findDeployment(idOrName);
|
|
25
|
+
const deploymentId = localDep?.id ?? idOrName;
|
|
26
|
+
|
|
27
|
+
console.log(chalk.bold(`\nš Logs: ${localDep?.name ?? deploymentId}\n`));
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const data = await getDeploymentLogs(config, deploymentId);
|
|
31
|
+
const lines = data?.logs ?? [];
|
|
32
|
+
if (lines.length === 0) {
|
|
33
|
+
console.log(chalk.dim(' No log lines available yet.\n'));
|
|
34
|
+
} else {
|
|
35
|
+
lines.forEach(l => console.log(` ${chalk.dim(l)}`));
|
|
36
|
+
}
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.log(chalk.yellow(` Could not fetch logs: ${err.message}`));
|
|
39
|
+
if (localDep) {
|
|
40
|
+
console.log(chalk.dim(`\n Provider: ${localDep.provider} GPU: ${localDep.gpu} Type: ${localDep.type}`));
|
|
41
|
+
console.log(chalk.dim(` Endpoint: ${localDep.endpointUrl}`));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (follow) {
|
|
46
|
+
console.log(chalk.yellow('\n --follow: live log streaming not yet supported. Poll with `badgr logs`.\n'));
|
|
47
|
+
}
|
|
48
|
+
console.log();
|
|
49
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { listModels } from '../api.js';
|
|
2
|
+
import { listAll } from '../router.js';
|
|
3
|
+
|
|
4
|
+
export async function modelsCommand(config, chalk) {
|
|
5
|
+
const gpus = listAll();
|
|
6
|
+
|
|
7
|
+
console.log(chalk.bold('\nš¦ GPU Options (cheapest first)\n'));
|
|
8
|
+
console.log(
|
|
9
|
+
` ${'ID'.padEnd(14)} ${'Name'.padEnd(24)} ${'VRAM'.padEnd(8)} Rate/hr`
|
|
10
|
+
);
|
|
11
|
+
console.log(` ${'ā'.repeat(58)}`);
|
|
12
|
+
gpus.forEach(g => {
|
|
13
|
+
console.log(
|
|
14
|
+
` ${chalk.cyan(g.id.padEnd(14))} ${g.name.padEnd(24)} ${`${g.vramGb}GB`.padEnd(8)} $${g.ratePerHour.toFixed(2)}`
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
console.log(chalk.bold('\nš¤ LLM Models\n'));
|
|
19
|
+
if (!config.apiKey) {
|
|
20
|
+
console.log(chalk.dim(' Run `gpu login` to see available models.\n'));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const data = await listModels(config);
|
|
26
|
+
const models = data.data ?? data.models ?? [];
|
|
27
|
+
if (models.length === 0) {
|
|
28
|
+
console.log(chalk.dim(' No models returned.\n'));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
models.forEach(m => {
|
|
32
|
+
const id = typeof m === 'string' ? m : m.id;
|
|
33
|
+
console.log(` ${chalk.cyan(id)}`);
|
|
34
|
+
});
|
|
35
|
+
console.log();
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.log(chalk.yellow(` Could not fetch models: ${err.message}\n`));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { listReceipts as localReceipts } from '../store.js';
|
|
2
|
+
import { listReceipts as apiReceipts, getReceipt as apiGetReceipt } from '../api.js';
|
|
3
|
+
|
|
4
|
+
function fmtMs(ms) {
|
|
5
|
+
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function printReceipt(r, chalk) {
|
|
9
|
+
const id = r.receiptId ?? r.request_id ?? r.id ?? 'ā';
|
|
10
|
+
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
|
+
const action = r.action ?? r.endpoint ?? 'ā';
|
|
15
|
+
|
|
16
|
+
console.log(` ${chalk.cyan(id)}`);
|
|
17
|
+
console.log(` ${chalk.bold('action:')} ${action}`);
|
|
18
|
+
console.log(` ${chalk.bold('provider:')} ${prov}`);
|
|
19
|
+
if (lat !== undefined) console.log(` ${chalk.bold('latency:')} ${fmtMs(lat)}`);
|
|
20
|
+
if (cost) console.log(` ${chalk.bold('cost:')} $${typeof cost === 'number' ? cost.toFixed(4) : cost}/hr`);
|
|
21
|
+
if (r.retries !== undefined) console.log(` ${chalk.bold('retries:')} ${r.retries}`);
|
|
22
|
+
if (r.status) console.log(` ${chalk.bold('status:')} ${r.status}`);
|
|
23
|
+
console.log(` ${chalk.dim(ts)}`);
|
|
24
|
+
console.log();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function receiptsCommand(config, args, chalk) {
|
|
28
|
+
// If first arg looks like a receipt/deployment ID, fetch that single receipt
|
|
29
|
+
const firstArg = args[0];
|
|
30
|
+
const isSingleLookup = firstArg && !/^\d+$/.test(firstArg);
|
|
31
|
+
|
|
32
|
+
if (isSingleLookup) {
|
|
33
|
+
console.log(chalk.bold(`\nš§¾ Receipt: ${firstArg}\n`));
|
|
34
|
+
if (!config.apiKey) {
|
|
35
|
+
console.log(chalk.dim(' Run `badgr login` to look up receipts from the API.\n'));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const r = await apiGetReceipt(config, firstArg);
|
|
40
|
+
printReceipt(r, chalk);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.log(chalk.yellow(` Receipt not found: ${err.message}\n`));
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const limit = parseInt(firstArg ?? '10', 10);
|
|
48
|
+
|
|
49
|
+
console.log(chalk.bold('\nš§¾ Receipts\n'));
|
|
50
|
+
|
|
51
|
+
// āā Local CLI action receipts (badgr run / badgr down) āāāāāāāāāāāāāāāāā
|
|
52
|
+
const local = localReceipts(limit);
|
|
53
|
+
if (local.length > 0) {
|
|
54
|
+
console.log(chalk.bold(' CLI Actions\n'));
|
|
55
|
+
local.forEach(r => printReceipt(r, chalk));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// āā Backend receipts (GET /v1/receipts) āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
59
|
+
if (!config.apiKey) {
|
|
60
|
+
console.log(chalk.dim(' Run `badgr login` to see inference receipts from the API.\n'));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log(chalk.bold(' API Receipts\n'));
|
|
65
|
+
try {
|
|
66
|
+
const data = await apiReceipts(config, { limit });
|
|
67
|
+
const rows = data?.data ?? data?.receipts ?? [];
|
|
68
|
+
|
|
69
|
+
if (rows.length === 0) {
|
|
70
|
+
console.log(chalk.dim(' No receipts found.\n'));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
rows.forEach(r => printReceipt(r, chalk));
|
|
75
|
+
|
|
76
|
+
if (data?.has_more) {
|
|
77
|
+
console.log(chalk.dim(` ⦠more available. badgr receipts ${limit * 2}\n`));
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
console.log(chalk.yellow(` Could not fetch API receipts: ${err.message}\n`));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { requireApiKey } from '../config.js';
|
|
2
|
+
import { callApi } from '../api.js';
|
|
3
|
+
import { addReceipt, generateReceiptId } from '../store.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* badgr run python train.py --gpu A100 # attached (default)
|
|
7
|
+
* badgr run --image my/image:latest --gpu L40S --detach
|
|
8
|
+
*
|
|
9
|
+
* Attaches by default: polls status + streams logs until the job finishes,
|
|
10
|
+
* then exits with the job's exit code. Pass --detach to return immediately.
|
|
11
|
+
*/
|
|
12
|
+
export function parseRunArgs(args) {
|
|
13
|
+
const flags = {};
|
|
14
|
+
const positional = [];
|
|
15
|
+
let i = 0;
|
|
16
|
+
while (i < args.length) {
|
|
17
|
+
if (args[i] === '--gpu') { flags.gpu = args[++i]; i++; continue; }
|
|
18
|
+
if (args[i] === '--image') { flags.image = args[++i]; i++; continue; }
|
|
19
|
+
if (args[i] === '--count') { flags.count = parseInt(args[++i], 10); i++; continue; }
|
|
20
|
+
if (args[i] === '--region') { flags.region = args[++i]; i++; continue; }
|
|
21
|
+
if (args[i] === '--max-price') { flags.maxPrice = parseFloat(args[++i]); i++; continue; }
|
|
22
|
+
if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
|
|
23
|
+
if (args[i] === '--detach') { flags.detach = true; i++; continue; }
|
|
24
|
+
positional.push(args[i++]);
|
|
25
|
+
}
|
|
26
|
+
return { flags, positional };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Poll /v1/deployments/:id until terminal status, streaming new log lines.
|
|
30
|
+
async function attachToJob(config, depId, chalk) {
|
|
31
|
+
const TERMINAL = new Set(['stopped', 'failed', 'completed']);
|
|
32
|
+
const POLL_MS = 4000;
|
|
33
|
+
let seenLines = 0;
|
|
34
|
+
let lastStatus = '';
|
|
35
|
+
|
|
36
|
+
while (true) {
|
|
37
|
+
await new Promise(r => setTimeout(r, POLL_MS));
|
|
38
|
+
|
|
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
|
+
}
|
|
50
|
+
|
|
51
|
+
const status = dep.status;
|
|
52
|
+
if (status !== lastStatus) {
|
|
53
|
+
if (status === 'running' && lastStatus === 'provisioning') {
|
|
54
|
+
console.log(chalk.dim(' [running]'));
|
|
55
|
+
}
|
|
56
|
+
lastStatus = status;
|
|
57
|
+
}
|
|
58
|
+
|
|
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])}`);
|
|
68
|
+
}
|
|
69
|
+
seenLines = lines.length;
|
|
70
|
+
} catch {
|
|
71
|
+
// logs not ready yet
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (TERMINAL.has(status)) {
|
|
75
|
+
return status;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function runCommand(config, args, chalk) {
|
|
81
|
+
const { flags, positional } = parseRunArgs(args);
|
|
82
|
+
|
|
83
|
+
if (positional.length === 0 && !flags.image) {
|
|
84
|
+
console.error(chalk.red('Usage: badgr run <command...> --gpu <type>'));
|
|
85
|
+
console.error(chalk.red(' badgr run --image my/image:latest --gpu A100'));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
requireApiKey(config);
|
|
90
|
+
|
|
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;
|
|
95
|
+
|
|
96
|
+
console.log(chalk.bold('\nā” Running GPU job\n'));
|
|
97
|
+
if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
|
|
98
|
+
if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
|
|
99
|
+
console.log(` ${chalk.bold('GPU:')} ${gpu}`);
|
|
100
|
+
if (detach) console.log(` ${chalk.dim('(detached ā returns immediately)')}`);
|
|
101
|
+
console.log();
|
|
102
|
+
console.log(chalk.dim(' Routing own GPUs ā Vast/RunPod/Salad overflow...'));
|
|
103
|
+
|
|
104
|
+
let dep;
|
|
105
|
+
try {
|
|
106
|
+
dep = await callApi('/run', {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
apiKey: config.apiKey,
|
|
109
|
+
baseUrl: config.baseUrl,
|
|
110
|
+
body: {
|
|
111
|
+
command,
|
|
112
|
+
image,
|
|
113
|
+
gpu: gpu.toUpperCase().replace('-', '_'),
|
|
114
|
+
gpu_count: flags.count || 1,
|
|
115
|
+
region: flags.region || 'US',
|
|
116
|
+
max_price_per_hour: flags.maxPrice,
|
|
117
|
+
name: flags.name,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.error(chalk.red(`\n ā Job failed to start: ${err.message}\n`));
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const rcptId = dep.receipt_id || generateReceiptId();
|
|
126
|
+
addReceipt({
|
|
127
|
+
receiptId: rcptId,
|
|
128
|
+
action: 'badgr run',
|
|
129
|
+
deploymentId: dep.deployment_id,
|
|
130
|
+
provider: dep.provider,
|
|
131
|
+
gpu: dep.gpu_type,
|
|
132
|
+
status: dep.status,
|
|
133
|
+
createdAt: new Date().toISOString(),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
console.log(chalk.bold(`\n Job ID: ${chalk.cyan(dep.deployment_id)}`));
|
|
137
|
+
console.log(` ${chalk.bold('Provider:')} ${dep.provider}`);
|
|
138
|
+
console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} Ć ${dep.gpu_count}`);
|
|
139
|
+
if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
|
|
140
|
+
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
141
|
+
|
|
142
|
+
if (detach) {
|
|
143
|
+
console.log(`\n ${chalk.bold('Logs:')} ${dep.logs_url || `badgr logs ${dep.deployment_id}`}`);
|
|
144
|
+
console.log(chalk.dim(`\n Detached. Track progress: badgr logs ${dep.deployment_id}\n`));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// āā Attached mode: stream logs until job completes āāāāāāāāāāāāāāāāāāāāāāā
|
|
149
|
+
console.log(chalk.dim('\n āā Attaching (Ctrl+C to detach) āāāāāāāāāāāāāāāāāāāāāāāāāāāāāā\n'));
|
|
150
|
+
|
|
151
|
+
const finalStatus = await attachToJob(config, dep.deployment_id, chalk);
|
|
152
|
+
|
|
153
|
+
console.log();
|
|
154
|
+
|
|
155
|
+
if (finalStatus === 'failed') {
|
|
156
|
+
console.error(chalk.red(`\n ā Job failed (${dep.deployment_id})\n`));
|
|
157
|
+
console.error(chalk.dim(` Logs: badgr logs ${dep.deployment_id}\n`));
|
|
158
|
+
process.exit(1);
|
|
159
|
+
} else {
|
|
160
|
+
console.log(chalk.green(`\n ā Job complete (${finalStatus})\n`));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { requireApiKey } from '../config.js';
|
|
2
|
+
import { callApi } from '../api.js';
|
|
3
|
+
import { addDeployment, addReceipt, generateReceiptId } from '../store.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S
|
|
7
|
+
*
|
|
8
|
+
* Provisions a persistent vLLM endpoint, health-checks it before printing
|
|
9
|
+
* "Endpoint ready", and returns an OpenAI-compatible base URL.
|
|
10
|
+
* Stop billing with `badgr down <id>`.
|
|
11
|
+
*/
|
|
12
|
+
export function parseServeArgs(args) {
|
|
13
|
+
const flags = {};
|
|
14
|
+
const positional = [];
|
|
15
|
+
let i = 0;
|
|
16
|
+
while (i < args.length) {
|
|
17
|
+
if (args[i] === '--gpu') { flags.gpu = args[++i]; i++; continue; }
|
|
18
|
+
if (args[i] === '--count') { flags.count = parseInt(args[++i], 10); i++; continue; }
|
|
19
|
+
if (args[i] === '--region') { flags.region = args[++i]; i++; continue; }
|
|
20
|
+
if (args[i] === '--max-price') { flags.maxPrice = parseFloat(args[++i]); i++; continue; }
|
|
21
|
+
if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
|
|
22
|
+
if (args[i] === '--no-wait') { flags.noWait = true; i++; continue; }
|
|
23
|
+
positional.push(args[i++]);
|
|
24
|
+
}
|
|
25
|
+
const model = positional[0] || null;
|
|
26
|
+
return { model, flags };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Poll GET <endpointUrl>/models until it returns 200 or timeout expires.
|
|
30
|
+
// Returns true if healthy, false if timed out.
|
|
31
|
+
async function waitForEndpoint(endpointUrl, timeoutMs = 5 * 60 * 1000, chalk) {
|
|
32
|
+
const deadline = Date.now() + timeoutMs;
|
|
33
|
+
const modelsUrl = `${endpointUrl}/models`;
|
|
34
|
+
let attempt = 0;
|
|
35
|
+
|
|
36
|
+
while (Date.now() < deadline) {
|
|
37
|
+
attempt++;
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(modelsUrl, { signal: AbortSignal.timeout(8000) });
|
|
40
|
+
if (res.ok) return true;
|
|
41
|
+
} catch {
|
|
42
|
+
// network not up yet ā keep polling
|
|
43
|
+
}
|
|
44
|
+
const elapsed = Math.round((Date.now() - (deadline - timeoutMs)) / 1000);
|
|
45
|
+
process.stdout.write(
|
|
46
|
+
`\r ${chalk.dim(`Waiting for endpoint⦠${elapsed}s (attempt ${attempt})`)} `
|
|
47
|
+
);
|
|
48
|
+
await new Promise(r => setTimeout(r, 8000));
|
|
49
|
+
}
|
|
50
|
+
process.stdout.write('\n');
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function serveCommand(config, args, chalk) {
|
|
55
|
+
const { model, flags } = parseServeArgs(args);
|
|
56
|
+
|
|
57
|
+
if (!model) {
|
|
58
|
+
console.error(chalk.red('Usage: badgr serve <model> --gpu <type>'));
|
|
59
|
+
console.error(chalk.red(' badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S'));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
requireApiKey(config);
|
|
64
|
+
|
|
65
|
+
const gpu = flags.gpu || 'L40S';
|
|
66
|
+
|
|
67
|
+
console.log(chalk.bold('\nš Serving model\n'));
|
|
68
|
+
console.log(` ${chalk.bold('Model:')} ${model}`);
|
|
69
|
+
console.log(` ${chalk.bold('GPU:')} ${gpu}`);
|
|
70
|
+
console.log();
|
|
71
|
+
console.log(chalk.dim(' Provisioning vLLM endpoint (own GPUs ā overflow providers)...'));
|
|
72
|
+
|
|
73
|
+
let dep;
|
|
74
|
+
try {
|
|
75
|
+
dep = await callApi('/serve', {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
apiKey: config.apiKey,
|
|
78
|
+
baseUrl: config.baseUrl,
|
|
79
|
+
body: {
|
|
80
|
+
model,
|
|
81
|
+
gpu: gpu.toUpperCase().replace('-', '_'),
|
|
82
|
+
gpu_count: flags.count || 1,
|
|
83
|
+
region: flags.region || 'US',
|
|
84
|
+
max_price_per_hour: flags.maxPrice,
|
|
85
|
+
name: flags.name,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error(chalk.red(`\n ā Serve failed: ${err.message}\n`));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Mirror to local store so badgr down/status/receipts work offline
|
|
94
|
+
addDeployment({
|
|
95
|
+
id: dep.deployment_id,
|
|
96
|
+
name: dep.name,
|
|
97
|
+
type: 'endpoint',
|
|
98
|
+
model: dep.model || model,
|
|
99
|
+
gpu: dep.gpu_type,
|
|
100
|
+
count: dep.gpu_count,
|
|
101
|
+
provider: dep.provider,
|
|
102
|
+
status: dep.status,
|
|
103
|
+
endpointUrl: dep.endpoint_url || dep.openai_base_url,
|
|
104
|
+
receiptId: dep.receipt_id,
|
|
105
|
+
createdAt: new Date().toISOString(),
|
|
106
|
+
costPerHour: dep.cost_per_hour || 0,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const rcptId = dep.receipt_id || generateReceiptId();
|
|
110
|
+
addReceipt({
|
|
111
|
+
receiptId: rcptId,
|
|
112
|
+
action: 'badgr serve',
|
|
113
|
+
deploymentId: dep.deployment_id,
|
|
114
|
+
provider: dep.provider,
|
|
115
|
+
gpu: dep.gpu_type,
|
|
116
|
+
status: dep.status,
|
|
117
|
+
createdAt: new Date().toISOString(),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const endpointUrl = dep.endpoint_url || dep.openai_base_url || config.baseUrl;
|
|
121
|
+
|
|
122
|
+
// āā Health check before declaring "ready" āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
123
|
+
let endpointReady = false;
|
|
124
|
+
|
|
125
|
+
if (flags.noWait) {
|
|
126
|
+
console.log(chalk.yellow('\nā³ Endpoint provisioning (skipped health check ā use --no-wait)\n'));
|
|
127
|
+
} else {
|
|
128
|
+
console.log(chalk.dim('\n Health-checking endpoint (up to 5 min)...'));
|
|
129
|
+
endpointReady = await waitForEndpoint(endpointUrl, 5 * 60 * 1000, chalk);
|
|
130
|
+
process.stdout.write('\n');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// āā Print result āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
134
|
+
if (endpointReady) {
|
|
135
|
+
console.log(chalk.green('ā Endpoint ready\n'));
|
|
136
|
+
} else {
|
|
137
|
+
console.log(chalk.yellow('ā³ Endpoint starting\n'));
|
|
138
|
+
console.log(chalk.dim(` Not yet responding. Check status:`));
|
|
139
|
+
console.log(chalk.dim(` badgr status`));
|
|
140
|
+
console.log(chalk.dim(` curl ${endpointUrl}/models`));
|
|
141
|
+
console.log();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
console.log(` ${chalk.bold('Deployment:')} ${chalk.cyan(dep.deployment_id)}`);
|
|
145
|
+
console.log(` ${chalk.bold('Model:')} ${dep.model || model}`);
|
|
146
|
+
console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} Ć ${dep.gpu_count}`);
|
|
147
|
+
console.log(` ${chalk.bold('Provider:')} ${dep.provider}`);
|
|
148
|
+
console.log(` ${chalk.bold('Endpoint:')} ${chalk.cyan(endpointUrl)}`);
|
|
149
|
+
if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
|
|
150
|
+
console.log(`\n ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
151
|
+
|
|
152
|
+
if (endpointReady) {
|
|
153
|
+
console.log(`\n ${chalk.bold('Use with OpenAI SDK:')}`);
|
|
154
|
+
console.log(chalk.dim(` from openai import OpenAI`));
|
|
155
|
+
console.log(chalk.dim(` client = OpenAI(api_key="${config.apiKey?.slice(0, 8) || 'sk-...'}...", base_url="${endpointUrl}")`));
|
|
156
|
+
console.log(chalk.dim(` resp = client.chat.completions.create(model="${dep.model || model}", messages=[...])`));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
console.log(`\n ${chalk.dim(`Stop billing: badgr down ${dep.deployment_id}`)}\n`);
|
|
160
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { findCheapest, findById } from '../router.js';
|
|
2
|
+
import { requireApiKey } from '../config.js';
|
|
3
|
+
|
|
4
|
+
export async function shellCommand(config, args, chalk) {
|
|
5
|
+
const gpuFlag = args.indexOf('--gpu');
|
|
6
|
+
const gpuId = gpuFlag !== -1 ? args[gpuFlag + 1] : null;
|
|
7
|
+
|
|
8
|
+
requireApiKey(config);
|
|
9
|
+
|
|
10
|
+
const gpu = gpuId ? findById(gpuId) : findCheapest({ tag: 'dev' });
|
|
11
|
+
|
|
12
|
+
console.log(chalk.bold('\nš» GPU Shell\n'));
|
|
13
|
+
if (gpu) {
|
|
14
|
+
console.log(` ${chalk.bold('GPU:')} ${gpu.name} ${chalk.dim(`$${gpu.ratePerHour}/hr`)}\n`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Real implementation: provision an instance, then SSH/exec into it
|
|
18
|
+
console.log(chalk.dim(' Provisioning instance...'));
|
|
19
|
+
console.log(chalk.yellow('\n GPU shell requires a running deployment.'));
|
|
20
|
+
console.log(chalk.dim(' First deploy with `gpu deploy <script.py>`, then connect with `gpu shell`.\n'));
|
|
21
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { listDeployments as localDeployments } from '../store.js';
|
|
2
|
+
import { listDeployments as apiDeployments } from '../api.js';
|
|
3
|
+
|
|
4
|
+
export async function statusCommand(config, args, chalk) {
|
|
5
|
+
console.log(chalk.bold('\nš GPU Status\n'));
|
|
6
|
+
|
|
7
|
+
let deployments = [];
|
|
8
|
+
|
|
9
|
+
// āā Live data from the backend āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
10
|
+
if (config.apiKey) {
|
|
11
|
+
try {
|
|
12
|
+
const data = await apiDeployments(config);
|
|
13
|
+
deployments = data?.deployments ?? [];
|
|
14
|
+
} catch (err) {
|
|
15
|
+
console.log(chalk.yellow(` Could not reach API: ${err.message}. Showing local state.\n`));
|
|
16
|
+
deployments = localDeployments().map(d => ({
|
|
17
|
+
deployment_id: d.id,
|
|
18
|
+
name: d.name,
|
|
19
|
+
workload_type: d.type,
|
|
20
|
+
gpu_type: d.gpu,
|
|
21
|
+
gpu_count: d.count,
|
|
22
|
+
provider: d.provider,
|
|
23
|
+
status: d.status,
|
|
24
|
+
cost_per_hour: d.costPerHour,
|
|
25
|
+
endpoint_url: d.endpointUrl,
|
|
26
|
+
model: d.model,
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
deployments = localDeployments().map(d => ({
|
|
31
|
+
deployment_id: d.id,
|
|
32
|
+
name: d.name,
|
|
33
|
+
workload_type: d.type,
|
|
34
|
+
gpu_type: d.gpu,
|
|
35
|
+
gpu_count: d.count,
|
|
36
|
+
provider: d.provider,
|
|
37
|
+
status: d.status,
|
|
38
|
+
cost_per_hour: d.costPerHour,
|
|
39
|
+
endpoint_url: d.endpointUrl,
|
|
40
|
+
model: d.model,
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
|
|
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'));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const cols = { name: 16, type: 9, gpu: 11, provider: 12, status: 14, rate: 9 };
|
|
51
|
+
|
|
52
|
+
const header =
|
|
53
|
+
'Name'.padEnd(cols.name) +
|
|
54
|
+
'Type'.padEnd(cols.type) +
|
|
55
|
+
'GPU'.padEnd(cols.gpu) +
|
|
56
|
+
'Provider'.padEnd(cols.provider) +
|
|
57
|
+
'Status'.padEnd(cols.status) +
|
|
58
|
+
'Rate/hr';
|
|
59
|
+
console.log(' ' + chalk.bold(header));
|
|
60
|
+
console.log(' ' + 'ā'.repeat(Object.values(cols).reduce((a, b) => a + b, 0) + 8));
|
|
61
|
+
|
|
62
|
+
deployments.forEach(d => {
|
|
63
|
+
const statusColor = d.status === 'running'
|
|
64
|
+
? chalk.green(d.status.padEnd(cols.status))
|
|
65
|
+
: d.status === 'provisioning'
|
|
66
|
+
? chalk.yellow(d.status.padEnd(cols.status))
|
|
67
|
+
: chalk.dim(d.status.padEnd(cols.status));
|
|
68
|
+
const rate = d.cost_per_hour > 0 ? `$${d.cost_per_hour.toFixed(2)}` : 'own-host';
|
|
69
|
+
const name = (d.name || d.deployment_id || '').slice(0, cols.name - 1);
|
|
70
|
+
const provider = (d.provider || 'ā').padEnd(cols.provider);
|
|
71
|
+
const gpu = (d.gpu_type || 'ā').slice(0, cols.gpu - 1).padEnd(cols.gpu);
|
|
72
|
+
console.log(
|
|
73
|
+
' ' +
|
|
74
|
+
name.padEnd(cols.name) +
|
|
75
|
+
(d.workload_type || 'ā').padEnd(cols.type) +
|
|
76
|
+
gpu +
|
|
77
|
+
provider +
|
|
78
|
+
statusColor +
|
|
79
|
+
rate
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
console.log();
|
|
84
|
+
|
|
85
|
+
// Show endpoint URLs for running endpoint deployments
|
|
86
|
+
const endpoints = deployments.filter(d => d.workload_type === 'endpoint' && d.status === 'running');
|
|
87
|
+
if (endpoints.length > 0) {
|
|
88
|
+
console.log(chalk.bold(' Endpoints\n'));
|
|
89
|
+
endpoints.forEach(d => {
|
|
90
|
+
const url = d.endpoint_url || config.baseUrl;
|
|
91
|
+
console.log(` ${chalk.cyan(d.name || d.deployment_id)}`);
|
|
92
|
+
console.log(` ${chalk.dim('URL:')} ${url}`);
|
|
93
|
+
if (d.model) console.log(` ${chalk.dim('model:')} ${d.model}`);
|
|
94
|
+
});
|
|
95
|
+
console.log();
|
|
96
|
+
}
|
|
97
|
+
}
|