badgr-cli 1.0.30 → 1.0.31
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 +4 -4
- package/package.json +4 -12
- package/src/api.js +10 -87
- package/src/badgr.js +37 -67
- package/src/commands/down.js +23 -26
- package/src/commands/login.js +7 -23
- package/src/commands/logs.js +5 -57
- package/src/commands/models.js +6 -25
- package/src/commands/receipts.js +4 -19
- package/src/commands/run.js +90 -458
- package/src/commands/serve.js +60 -132
- package/src/commands/status.js +48 -35
- package/src/commands/up.js +26 -32
- package/src/config.js +4 -49
- package/src/router.js +73 -16
- package/src/store.js +1 -10
- package/tests/commands.test.js +2 -183
- package/tests/config.test.js +1 -24
- package/tests/router.test.js +68 -9
- package/tests/store.test.js +1 -41
- package/src/commands/billing.js +0 -93
- package/src/commands/capacity.js +0 -111
- package/src/commands/test-run.js +0 -240
- package/src/fallback.js +0 -170
package/src/commands/serve.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { requireApiKey } from '../config.js';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { callApi } from '../api.js';
|
|
3
|
+
import { addDeployment, addReceipt, generateReceiptId } from '../store.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* badgr serve meta-llama/Llama-3.1-8B-Instruct
|
|
7
6
|
* badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S
|
|
8
7
|
*
|
|
9
|
-
*
|
|
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>`.
|
|
10
11
|
*/
|
|
11
12
|
export function parseServeArgs(args) {
|
|
12
13
|
const flags = {};
|
|
@@ -16,56 +17,33 @@ export function parseServeArgs(args) {
|
|
|
16
17
|
if (args[i] === '--gpu') { flags.gpu = args[++i]; i++; continue; }
|
|
17
18
|
if (args[i] === '--count') { flags.count = parseInt(args[++i], 10); i++; continue; }
|
|
18
19
|
if (args[i] === '--region') { flags.region = args[++i]; i++; continue; }
|
|
19
|
-
if (args[i] === '--tier') { flags.tier = args[++i]; i++; continue; }
|
|
20
20
|
if (args[i] === '--max-price') { flags.maxPrice = parseFloat(args[++i]); i++; continue; }
|
|
21
21
|
if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
|
|
22
|
-
if (args[i] === '--no-wait')
|
|
23
|
-
if (args[i] === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
|
|
24
|
-
if (args[i] === '--strict-capacity') { flags.noMarketplaceFallback = true; i++; continue; }
|
|
25
|
-
if (args[i] === '--no-expanded-search') { flags.noMarketplaceFallback = true; i++; continue; }
|
|
22
|
+
if (args[i] === '--no-wait') { flags.noWait = true; i++; continue; }
|
|
26
23
|
positional.push(args[i++]);
|
|
27
24
|
}
|
|
28
25
|
const model = positional[0] || null;
|
|
29
26
|
return { model, flags };
|
|
30
27
|
}
|
|
31
28
|
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
const s = modelName.toLowerCase();
|
|
35
|
-
const moe = s.match(/(\d+)x(\d+)b/);
|
|
36
|
-
let paramsB;
|
|
37
|
-
if (moe) {
|
|
38
|
-
paramsB = parseInt(moe[1]) * parseInt(moe[2]);
|
|
39
|
-
} else {
|
|
40
|
-
const m = s.match(/(\d+)b/);
|
|
41
|
-
paramsB = m ? parseInt(m[1]) : null;
|
|
42
|
-
}
|
|
43
|
-
if (paramsB === null) return { label: 'inference (7B–8B model)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] };
|
|
44
|
-
if (paramsB <= 9) return { label: 'inference (7B–8B model)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] };
|
|
45
|
-
if (paramsB <= 35) return { label: 'inference (30B–34B model)', vram: '40+ GB', gpus: ['A6000', 'L40S', 'A100'] };
|
|
46
|
-
return { label: 'inference (70B+ model)', vram: '80+ GB', gpus: ['H100', 'A100'] };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function _serveStageLabel(elapsedSec) {
|
|
50
|
-
if (elapsedSec < 45) return 'Starting vLLM…';
|
|
51
|
-
if (elapsedSec < 150) return 'Downloading model…';
|
|
52
|
-
return 'Waiting for /v1/models…';
|
|
53
|
-
}
|
|
54
|
-
|
|
29
|
+
// Poll GET <endpointUrl>/models until it returns 200 or timeout expires.
|
|
30
|
+
// Returns true if healthy, false if timed out.
|
|
55
31
|
async function waitForEndpoint(endpointUrl, timeoutMs = 5 * 60 * 1000, chalk) {
|
|
56
|
-
const
|
|
57
|
-
const
|
|
32
|
+
const deadline = Date.now() + timeoutMs;
|
|
33
|
+
const modelsUrl = `${endpointUrl}/models`;
|
|
34
|
+
let attempt = 0;
|
|
58
35
|
|
|
59
36
|
while (Date.now() < deadline) {
|
|
37
|
+
attempt++;
|
|
60
38
|
try {
|
|
61
|
-
const res = await fetch(
|
|
62
|
-
if (res.ok)
|
|
39
|
+
const res = await fetch(modelsUrl, { signal: AbortSignal.timeout(8000) });
|
|
40
|
+
if (res.ok) return true;
|
|
63
41
|
} catch {
|
|
64
|
-
//
|
|
42
|
+
// network not up yet — keep polling
|
|
65
43
|
}
|
|
66
|
-
const elapsed = Math.round((Date.now() -
|
|
44
|
+
const elapsed = Math.round((Date.now() - (deadline - timeoutMs)) / 1000);
|
|
67
45
|
process.stdout.write(
|
|
68
|
-
`\r ${chalk.dim(
|
|
46
|
+
`\r ${chalk.dim(`Waiting for endpoint… ${elapsed}s (attempt ${attempt})`)} `
|
|
69
47
|
);
|
|
70
48
|
await new Promise(r => setTimeout(r, 8000));
|
|
71
49
|
}
|
|
@@ -77,81 +55,42 @@ export async function serveCommand(config, args, chalk) {
|
|
|
77
55
|
const { model, flags } = parseServeArgs(args);
|
|
78
56
|
|
|
79
57
|
if (!model) {
|
|
80
|
-
console.error(chalk.red('Usage: badgr serve <model>'));
|
|
81
|
-
console.error(chalk.red(' badgr serve meta-llama/Llama-3.1-8B-Instruct'));
|
|
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'));
|
|
82
60
|
return;
|
|
83
61
|
}
|
|
84
62
|
|
|
85
63
|
requireApiKey(config);
|
|
86
64
|
|
|
87
|
-
|
|
88
|
-
const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : 'AUTO';
|
|
89
|
-
const gpuLabel = gpu === 'AUTO' ? 'auto' : gpu;
|
|
65
|
+
const gpu = flags.gpu || 'L40S';
|
|
90
66
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
console.log(chalk.bold('\n⚡ Serving model\n'));
|
|
67
|
+
console.log(chalk.bold('\n🚀 Serving model\n'));
|
|
94
68
|
console.log(` ${chalk.bold('Model:')} ${model}`);
|
|
95
|
-
console.log(` ${chalk.bold('GPU:')} ${
|
|
96
|
-
|
|
97
|
-
// Show inferred workload so the user knows what Badgr detected.
|
|
98
|
-
if (gpu === 'AUTO') {
|
|
99
|
-
const prof = _inferServeProfile(model);
|
|
100
|
-
console.log();
|
|
101
|
-
console.log(` ${chalk.bold('Estimated workload:')} ${prof.label}`);
|
|
102
|
-
console.log(` ${chalk.bold('Estimated minimum VRAM:')} ${prof.vram}`);
|
|
103
|
-
if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
|
|
104
|
-
}
|
|
69
|
+
console.log(` ${chalk.bold('GPU:')} ${gpu}`);
|
|
105
70
|
console.log();
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
|
|
109
|
-
|
|
110
|
-
function buildBody(gpuOverride, tierOverride) {
|
|
111
|
-
return {
|
|
112
|
-
model,
|
|
113
|
-
gpu: gpuOverride || gpu,
|
|
114
|
-
gpu_count: flags.count || 1,
|
|
115
|
-
...(effectiveRegion ? { region: effectiveRegion } : {}),
|
|
116
|
-
max_price_per_hour: flags.maxPrice,
|
|
117
|
-
name: flags.name,
|
|
118
|
-
tier: tierOverride || effectiveTier,
|
|
119
|
-
};
|
|
120
|
-
}
|
|
71
|
+
console.log(chalk.dim(' Finding best available GPU capacity...'));
|
|
121
72
|
|
|
122
73
|
let dep;
|
|
123
74
|
try {
|
|
124
|
-
dep = await
|
|
125
|
-
'
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
action: 'badgr serve',
|
|
137
|
-
model,
|
|
138
|
-
gpu: gpuLabel,
|
|
139
|
-
status: 'failed',
|
|
140
|
-
failureType: 'infrastructure',
|
|
141
|
-
createdAt: new Date().toISOString(),
|
|
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
|
+
},
|
|
142
87
|
});
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const rerun = `badgr serve ${args.join(' ')}`;
|
|
146
|
-
console.error(chalk.dim(`After payment, rerun:\n ${rerun}\n`));
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
console.error(chalk.red(`\n ✗ Could not start endpoint: ${err.message}`));
|
|
150
|
-
console.error(chalk.dim(`\n Receipt: ${failRcptId}`));
|
|
151
|
-
console.error(chalk.dim(` Run BADGR_DEBUG=1 badgr serve … for a full trace.\n`));
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error(chalk.red(`\n ✗ Serve failed: ${err.message}\n`));
|
|
152
90
|
return;
|
|
153
91
|
}
|
|
154
92
|
|
|
93
|
+
// Mirror to local store so badgr down/status/receipts work offline
|
|
155
94
|
addDeployment({
|
|
156
95
|
id: dep.deployment_id,
|
|
157
96
|
name: dep.name,
|
|
@@ -159,13 +98,12 @@ export async function serveCommand(config, args, chalk) {
|
|
|
159
98
|
model: dep.model || model,
|
|
160
99
|
gpu: dep.gpu_type,
|
|
161
100
|
count: dep.gpu_count,
|
|
101
|
+
provider: dep.provider,
|
|
162
102
|
status: dep.status,
|
|
163
103
|
endpointUrl: dep.endpoint_url || dep.openai_base_url,
|
|
164
104
|
receiptId: dep.receipt_id,
|
|
165
105
|
createdAt: new Date().toISOString(),
|
|
166
106
|
costPerHour: dep.cost_per_hour || 0,
|
|
167
|
-
providerRoute: dep.provider ?? null,
|
|
168
|
-
tier: dep.tier ?? null,
|
|
169
107
|
});
|
|
170
108
|
|
|
171
109
|
const rcptId = dep.receipt_id || generateReceiptId();
|
|
@@ -173,59 +111,49 @@ export async function serveCommand(config, args, chalk) {
|
|
|
173
111
|
receiptId: rcptId,
|
|
174
112
|
action: 'badgr serve',
|
|
175
113
|
deploymentId: dep.deployment_id,
|
|
114
|
+
provider: dep.provider,
|
|
176
115
|
gpu: dep.gpu_type,
|
|
177
|
-
providerRoute: dep.provider ?? null,
|
|
178
|
-
tier: dep.tier ?? null,
|
|
179
116
|
status: dep.status,
|
|
180
117
|
createdAt: new Date().toISOString(),
|
|
181
118
|
});
|
|
182
119
|
|
|
183
120
|
const endpointUrl = dep.endpoint_url || dep.openai_base_url || config.baseUrl;
|
|
184
121
|
|
|
185
|
-
// ── Health check
|
|
122
|
+
// ── Health check before declaring "ready" ─────────────────────────────────
|
|
186
123
|
let endpointReady = false;
|
|
124
|
+
|
|
187
125
|
if (flags.noWait) {
|
|
188
|
-
console.log(chalk.yellow('\n
|
|
126
|
+
console.log(chalk.yellow('\n⏳ Endpoint provisioning (skipped health check — use --no-wait)\n'));
|
|
189
127
|
} else {
|
|
128
|
+
console.log(chalk.dim('\n Health-checking endpoint (up to 5 min)...'));
|
|
190
129
|
endpointReady = await waitForEndpoint(endpointUrl, 5 * 60 * 1000, chalk);
|
|
191
130
|
process.stdout.write('\n');
|
|
192
|
-
if (!endpointReady) updateReceipt(rcptId, { status: 'health_check_timeout' });
|
|
193
131
|
}
|
|
194
132
|
|
|
195
|
-
// ──
|
|
133
|
+
// ── Print result ──────────────────────────────────────────────────────────
|
|
196
134
|
if (endpointReady) {
|
|
197
|
-
console.log(chalk.green('
|
|
135
|
+
console.log(chalk.green('✓ Endpoint ready\n'));
|
|
198
136
|
} else {
|
|
199
|
-
console.log(chalk.yellow('
|
|
200
|
-
console.log(
|
|
201
|
-
console.log(
|
|
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`));
|
|
202
141
|
console.log();
|
|
203
142
|
}
|
|
204
143
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
console.log(` ${chalk.bold('
|
|
208
|
-
console.log(` ${chalk.bold('
|
|
209
|
-
console.log(` ${chalk.bold('
|
|
210
|
-
|
|
211
|
-
if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
|
|
212
|
-
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
213
|
-
console.log(` ${chalk.bold('Logs:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
|
|
214
|
-
console.log(` ${chalk.bold('Stop billing:')} ${chalk.cyan(`badgr down ${dep.deployment_id}`)}`);
|
|
215
|
-
console.log();
|
|
216
|
-
|
|
217
|
-
if (serveRate > HIGH_RATE_THRESHOLD && !flags.maxCost) {
|
|
218
|
-
console.log(chalk.yellow(` Selected capacity rate: $${serveRate.toFixed(2)}/hr`));
|
|
219
|
-
console.log(chalk.dim(' Tip: use --max-cost to enforce a hard ceiling.\n'));
|
|
220
|
-
}
|
|
221
|
-
console.log(chalk.dim(' Billing continues until you run: ') + chalk.cyan(`badgr down ${dep.deployment_id}`));
|
|
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('Endpoint:')} ${chalk.cyan(endpointUrl)}`);
|
|
148
|
+
if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
|
|
149
|
+
console.log(`\n ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
222
150
|
|
|
223
151
|
if (endpointReady) {
|
|
224
|
-
|
|
225
|
-
console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
|
|
152
|
+
console.log(`\n ${chalk.bold('Use with OpenAI SDK:')}`);
|
|
226
153
|
console.log(chalk.dim(` from openai import OpenAI`));
|
|
227
|
-
console.log(chalk.dim(` client = OpenAI(
|
|
154
|
+
console.log(chalk.dim(` client = OpenAI(api_key="${config.apiKey?.slice(0, 8) || 'sk-...'}...", base_url="${endpointUrl}")`));
|
|
228
155
|
console.log(chalk.dim(` resp = client.chat.completions.create(model="${dep.model || model}", messages=[...])`));
|
|
229
|
-
console.log();
|
|
230
156
|
}
|
|
157
|
+
|
|
158
|
+
console.log(`\n ${chalk.dim(`Stop billing: badgr down ${dep.deployment_id}`)}\n`);
|
|
231
159
|
}
|
package/src/commands/status.js
CHANGED
|
@@ -2,20 +2,24 @@ 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
|
+
|
|
5
7
|
let deployments = [];
|
|
6
8
|
|
|
9
|
+
// ── Live data from the backend ────────────────────────────────────────────
|
|
7
10
|
if (config.apiKey) {
|
|
8
11
|
try {
|
|
9
12
|
const data = await apiDeployments(config);
|
|
10
13
|
deployments = data?.deployments ?? [];
|
|
11
14
|
} catch (err) {
|
|
12
|
-
console.log(chalk.yellow(
|
|
15
|
+
console.log(chalk.yellow(` Could not reach API: ${err.message}. Showing local state.\n`));
|
|
13
16
|
deployments = localDeployments().map(d => ({
|
|
14
17
|
deployment_id: d.id,
|
|
15
18
|
name: d.name,
|
|
16
19
|
workload_type: d.type,
|
|
17
20
|
gpu_type: d.gpu,
|
|
18
21
|
gpu_count: d.count,
|
|
22
|
+
provider: d.provider,
|
|
19
23
|
status: d.status,
|
|
20
24
|
cost_per_hour: d.costPerHour,
|
|
21
25
|
endpoint_url: d.endpointUrl,
|
|
@@ -29,6 +33,7 @@ export async function statusCommand(config, args, chalk) {
|
|
|
29
33
|
workload_type: d.type,
|
|
30
34
|
gpu_type: d.gpu,
|
|
31
35
|
gpu_count: d.count,
|
|
36
|
+
provider: d.provider,
|
|
32
37
|
status: d.status,
|
|
33
38
|
cost_per_hour: d.costPerHour,
|
|
34
39
|
endpoint_url: d.endpointUrl,
|
|
@@ -36,46 +41,54 @@ export async function statusCommand(config, args, chalk) {
|
|
|
36
41
|
}));
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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'));
|
|
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'));
|
|
45
47
|
return;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
for (const d of running) {
|
|
50
|
-
const id = d.deployment_id || d.name;
|
|
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');
|
|
50
|
+
const cols = { name: 16, type: 9, gpu: 11, status: 14, price: 9 };
|
|
57
51
|
|
|
58
|
-
|
|
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));
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
});
|
|
68
79
|
|
|
69
|
-
|
|
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
|
-
}
|
|
80
|
+
console.log();
|
|
74
81
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
console.log(
|
|
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();
|
|
79
93
|
}
|
|
80
|
-
console.log();
|
|
81
94
|
}
|
package/src/commands/up.js
CHANGED
|
@@ -1,31 +1,37 @@
|
|
|
1
1
|
import { parseSpec, validateSpec, specLines } from '../spec.js';
|
|
2
|
+
import { getRoutePlan } from '../router.js';
|
|
2
3
|
import { requireApiKey } from '../config.js';
|
|
3
4
|
import { generateDeploymentId, generateReceiptId, addDeployment, addReceipt } from '../store.js';
|
|
4
|
-
import { createDeployment
|
|
5
|
+
import { createDeployment } from '../api.js';
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
const { matches = [], alternatives = [] } = suggestions;
|
|
7
|
+
const OVERHEAD_PCT = 0.25;
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
function printRoutePlan(plan, chalk) {
|
|
10
|
+
const { lane1, lane2, cheapestRate, costWithOverhead, canonical } = plan;
|
|
11
|
+
|
|
12
|
+
console.log(chalk.bold(' Lane 1 — Own GPU Hosts'));
|
|
10
13
|
console.log(` ${'─'.repeat(40)}`);
|
|
11
|
-
|
|
12
|
-
console.log(chalk.yellow(` No ${gpu} capacity found right now`));
|
|
13
|
-
} else {
|
|
14
|
-
const cheapest = matches[0];
|
|
15
|
-
console.log(` ${chalk.cyan(gpu)} available — from ${chalk.green('$' + cheapest.price.toFixed(2) + '/hr')}`);
|
|
16
|
-
console.log(chalk.dim(` ${matches.length} offer(s) found`));
|
|
17
|
-
}
|
|
14
|
+
console.log(chalk.dim(` ${lane1.description}`));
|
|
18
15
|
console.log();
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
console.log(chalk.bold(' Lane 2 — Overflow Providers (cheapest-first)'));
|
|
18
|
+
console.log(` ${'─'.repeat(40)}`);
|
|
19
|
+
if (lane2.length === 0) {
|
|
20
|
+
console.log(chalk.yellow(` No pricing data for ${canonical}`));
|
|
21
|
+
} else {
|
|
22
|
+
lane2.forEach((p, i) => {
|
|
23
|
+
const arrow = i === 0 ? chalk.green(' ← primary') : '';
|
|
24
|
+
console.log(
|
|
25
|
+
` ${String(i + 1)}. ${p.provider.padEnd(12)} ${canonical.padEnd(10)}` +
|
|
26
|
+
` $${p.ratePerHour.toFixed(2)}/hr` +
|
|
27
|
+
` reliability: ${Math.round(p.reliability * 100)}%${arrow}`
|
|
28
|
+
);
|
|
26
29
|
});
|
|
27
30
|
console.log();
|
|
31
|
+
console.log(` Estimated range: $${cheapestRate.toFixed(2)}–$${lane2[lane2.length - 1].ratePerHour.toFixed(2)}/hr`);
|
|
32
|
+
console.log(chalk.dim(` Estimated Badgr price: ~$${costWithOverhead.toFixed(2)}/hr`));
|
|
28
33
|
}
|
|
34
|
+
console.log();
|
|
29
35
|
console.log(chalk.dim(' Remove --dry-run to provision.'));
|
|
30
36
|
console.log();
|
|
31
37
|
}
|
|
@@ -39,27 +45,15 @@ export async function upCommand(config, args, chalk) {
|
|
|
39
45
|
return;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
// ── Dry-run: show
|
|
48
|
+
// ── Dry-run: show route plan and exit ─────────────────────────────────────
|
|
43
49
|
if (spec.dryRun) {
|
|
44
50
|
console.log(chalk.bold('\n🔍 Dry Run — Route Plan\n'));
|
|
45
51
|
console.log(chalk.bold(' Spec'));
|
|
46
52
|
console.log(` ${'─'.repeat(40)}`);
|
|
47
53
|
specLines(spec).forEach(l => console.log(` ${l}`));
|
|
48
54
|
console.log();
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
const params = new URLSearchParams({ gpu: spec.gpu, max_price: String(spec.maxPrice ?? 10) });
|
|
53
|
-
if (spec.region) params.set('region', spec.region);
|
|
54
|
-
const suggestions = await callApi(`/capacity/suggestions?${params}`, {
|
|
55
|
-
apiKey: config.apiKey,
|
|
56
|
-
baseUrl: config.baseUrl,
|
|
57
|
-
});
|
|
58
|
-
printLivePlan(suggestions, spec.gpu, chalk);
|
|
59
|
-
} catch (err) {
|
|
60
|
-
console.log(chalk.yellow(` Could not fetch live availability: ${err.message}`));
|
|
61
|
-
console.log(chalk.dim(' Tip: check `badgr capacity` for live data.\n'));
|
|
62
|
-
}
|
|
55
|
+
const plan = getRoutePlan(spec.gpu, spec.count);
|
|
56
|
+
printRoutePlan(plan, chalk);
|
|
63
57
|
return;
|
|
64
58
|
}
|
|
65
59
|
|
package/src/config.js
CHANGED
|
@@ -6,61 +6,16 @@ export const CONFIG_DIR = join(homedir(), '.badgr');
|
|
|
6
6
|
export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
7
7
|
|
|
8
8
|
export const DEFAULTS = {
|
|
9
|
-
baseUrl: 'https://
|
|
9
|
+
baseUrl: 'https://api.badgr.ai/v1',
|
|
10
10
|
defaultModel: 'meta-llama/Llama-3.1-8B-Instruct',
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
-
/** URLs that do not resolve or are superseded by aibadgr.com/v1 (nginx) */
|
|
14
|
-
const LEGACY_BASE_URLS = new Set([
|
|
15
|
-
'https://api.badgr.ai/v1',
|
|
16
|
-
'https://api.badgr.ai',
|
|
17
|
-
'http://api.badgr.ai/v1',
|
|
18
|
-
'http://api.badgr.ai',
|
|
19
|
-
'https://api.aibadgr.com/v1',
|
|
20
|
-
'https://api.aibadgr.com',
|
|
21
|
-
'http://api.aibadgr.com/v1',
|
|
22
|
-
'http://api.aibadgr.com',
|
|
23
|
-
]);
|
|
24
|
-
|
|
25
|
-
export function normalizeBaseUrl(url) {
|
|
26
|
-
if (!url?.trim()) return DEFAULTS.baseUrl;
|
|
27
|
-
const trimmed = url.trim().replace(/\/+$/, '');
|
|
28
|
-
if (LEGACY_BASE_URLS.has(trimmed)) return DEFAULTS.baseUrl;
|
|
29
|
-
return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function applyEnvOverrides(config) {
|
|
33
|
-
const envBase = process.env.BADGR_API_URL?.trim();
|
|
34
|
-
if (envBase) config.baseUrl = normalizeBaseUrl(envBase);
|
|
35
|
-
const envKey = process.env.BADGR_API_KEY?.trim();
|
|
36
|
-
if (envKey) config.apiKey = envKey;
|
|
37
|
-
return config;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
13
|
export function loadConfig(configFile = CONFIG_FILE) {
|
|
41
|
-
if (!existsSync(configFile)) {
|
|
42
|
-
return applyEnvOverrides({ ...DEFAULTS });
|
|
43
|
-
}
|
|
14
|
+
if (!existsSync(configFile)) return { ...DEFAULTS };
|
|
44
15
|
try {
|
|
45
|
-
|
|
46
|
-
const previousBase = parsed.baseUrl;
|
|
47
|
-
const config = applyEnvOverrides({
|
|
48
|
-
...DEFAULTS,
|
|
49
|
-
...parsed,
|
|
50
|
-
baseUrl: normalizeBaseUrl(parsed.baseUrl ?? DEFAULTS.baseUrl),
|
|
51
|
-
});
|
|
52
|
-
if (
|
|
53
|
-
configFile === CONFIG_FILE &&
|
|
54
|
-
previousBase &&
|
|
55
|
-
normalizeBaseUrl(previousBase) !== previousBase
|
|
56
|
-
) {
|
|
57
|
-
const merged = { ...parsed, baseUrl: config.baseUrl };
|
|
58
|
-
mkdirSync(dirname(configFile), { recursive: true });
|
|
59
|
-
writeFileSync(configFile, JSON.stringify(merged, null, 2));
|
|
60
|
-
}
|
|
61
|
-
return config;
|
|
16
|
+
return { ...DEFAULTS, ...JSON.parse(readFileSync(configFile, 'utf8')) };
|
|
62
17
|
} catch {
|
|
63
|
-
return
|
|
18
|
+
return { ...DEFAULTS };
|
|
64
19
|
}
|
|
65
20
|
}
|
|
66
21
|
|