badgr-cli 1.0.31 → 1.0.34
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/README.md +57 -27
- package/package.json +14 -6
- package/src/api.js +138 -20
- package/src/badgr.js +81 -37
- package/src/commands/billing.js +93 -0
- package/src/commands/capacity.js +111 -0
- package/src/commands/down.js +26 -23
- package/src/commands/login.js +23 -7
- package/src/commands/logs.js +57 -5
- package/src/commands/models.js +25 -6
- package/src/commands/receipts.js +23 -4
- package/src/commands/run.js +551 -86
- package/src/commands/serve.js +343 -66
- package/src/commands/status.js +35 -48
- package/src/commands/test-run.js +240 -0
- package/src/commands/up.js +32 -26
- package/src/config.js +49 -4
- package/src/errors.js +299 -0
- package/src/fallback.js +219 -0
- package/src/router.js +17 -73
- package/src/store.js +10 -1
- package/tests/commands.test.js +246 -2
- package/tests/config.test.js +24 -1
- package/tests/errors.test.js +130 -0
- package/tests/launch-readiness.test.js +326 -0
- package/tests/router.test.js +9 -68
- package/tests/run-lifecycle.test.js +508 -0
- package/tests/serve-lifecycle.test.js +499 -0
- package/tests/store.test.js +41 -1
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { requireApiKey } from '../config.js';
|
|
2
|
+
import { callApi, terminateDeployment } from '../api.js';
|
|
3
|
+
import { addReceipt, generateReceiptId } from '../store.js';
|
|
4
|
+
|
|
5
|
+
// max $0.80/hr × 2 min ≈ $0.027 total spend cap (smoke / Modal T4 tier)
|
|
6
|
+
const TEST_MAX_PRICE = 0.80;
|
|
7
|
+
const TEST_MAX_RUNTIME_MS = 2 * 60 * 1000;
|
|
8
|
+
const TEST_COMMAND = ['python', '-c', "print('hello from badgr')"];
|
|
9
|
+
// Use alpine (7MB) instead of slim (50MB) — dramatically faster image pull for smoke tests.
|
|
10
|
+
// Falls back gracefully: alpine has python3 and supports the test command identically.
|
|
11
|
+
const TEST_IMAGE = 'python:3.11-alpine';
|
|
12
|
+
const EXPECTED_OUTPUT = 'hello from badgr';
|
|
13
|
+
|
|
14
|
+
// --provider flag resolves to a backend tier value.
|
|
15
|
+
// 'tier1' → managed routing (default), 'tier2' → marketplace routing, 'secondary' → secondary dispatch.
|
|
16
|
+
const PROVIDER_TO_TIER = { tier1: '1', tier2: '2', secondary: 'modal' };
|
|
17
|
+
|
|
18
|
+
export function parseTestArgs(args) {
|
|
19
|
+
const flags = {};
|
|
20
|
+
let i = 0;
|
|
21
|
+
while (i < args.length) {
|
|
22
|
+
if (args[i] === '--provider' && args[i + 1]) { flags.provider = args[++i]; i++; continue; }
|
|
23
|
+
if (args[i] === '--no-tier-fallback') { flags.noTierFallback = true; i++; continue; }
|
|
24
|
+
i++;
|
|
25
|
+
}
|
|
26
|
+
return flags;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function step(chalk, ok, msg, detail = '') {
|
|
30
|
+
const icon = ok ? chalk.green('✓') : chalk.red('✗');
|
|
31
|
+
const suffix = detail ? chalk.dim(` — ${detail}`) : '';
|
|
32
|
+
console.log(` ${icon} ${msg}${suffix}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function pollStatus(config, depId, targetStatuses, timeoutMs) {
|
|
36
|
+
const deadline = Date.now() + timeoutMs;
|
|
37
|
+
while (Date.now() < deadline) {
|
|
38
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
39
|
+
try {
|
|
40
|
+
const dep = await callApi(`/deployments/${depId}`, {
|
|
41
|
+
apiKey: config.apiKey,
|
|
42
|
+
baseUrl: config.baseUrl,
|
|
43
|
+
});
|
|
44
|
+
if (targetStatuses.has(dep.status)) return dep;
|
|
45
|
+
} catch { /* retry */ }
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function pollOutputOrDone(config, depId, expected, timeoutMs) {
|
|
51
|
+
const deadline = Date.now() + timeoutMs;
|
|
52
|
+
while (Date.now() < deadline) {
|
|
53
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
54
|
+
try {
|
|
55
|
+
const dep = await callApi(`/deployments/${depId}`, {
|
|
56
|
+
apiKey: config.apiKey,
|
|
57
|
+
baseUrl: config.baseUrl,
|
|
58
|
+
});
|
|
59
|
+
if (dep.status === 'succeeded' || dep.status === 'failed') {
|
|
60
|
+
const data = await callApi(`/deployments/${depId}/logs`, {
|
|
61
|
+
apiKey: config.apiKey,
|
|
62
|
+
baseUrl: config.baseUrl,
|
|
63
|
+
});
|
|
64
|
+
const lines = data?.logs ?? [];
|
|
65
|
+
return {
|
|
66
|
+
done: true,
|
|
67
|
+
ok: dep.status === 'succeeded' && lines.some(l => l.includes(expected)),
|
|
68
|
+
exitCode: dep.exit_code,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const data = await callApi(`/deployments/${depId}/logs`, {
|
|
72
|
+
apiKey: config.apiKey,
|
|
73
|
+
baseUrl: config.baseUrl,
|
|
74
|
+
});
|
|
75
|
+
const lines = data?.logs ?? [];
|
|
76
|
+
if (lines.some(l => l.includes(expected))) return { done: true, ok: true, exitCode: 0 };
|
|
77
|
+
} catch { /* retry */ }
|
|
78
|
+
}
|
|
79
|
+
return { done: false, ok: false, exitCode: null };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function testCommand(config, args, chalk) {
|
|
83
|
+
requireApiKey(config);
|
|
84
|
+
|
|
85
|
+
const flags = parseTestArgs(Array.isArray(args) ? args : []);
|
|
86
|
+
const providerKey = flags.provider ? flags.provider.toLowerCase() : 'tier1';
|
|
87
|
+
|
|
88
|
+
if (providerKey === 'secondary') {
|
|
89
|
+
// Secondary dispatch provider uses a webhook model, not direct GPU rental.
|
|
90
|
+
// Verify the backend reports it as configured.
|
|
91
|
+
console.log(chalk.bold('\n⚡ Testing secondary dispatch provider\n'));
|
|
92
|
+
let routes;
|
|
93
|
+
try {
|
|
94
|
+
routes = await callApi('/compute/routes', { apiKey: config.apiKey, baseUrl: config.baseUrl });
|
|
95
|
+
} catch {
|
|
96
|
+
routes = null;
|
|
97
|
+
}
|
|
98
|
+
const secondaryRoute = Array.isArray(routes) ? routes.find(r => r.name === 'modal') : null;
|
|
99
|
+
if (secondaryRoute?.available) {
|
|
100
|
+
step(chalk, true, 'Secondary provider configured');
|
|
101
|
+
console.log(chalk.green('\n ✓ Secondary dispatch provider is ready\n'));
|
|
102
|
+
} else {
|
|
103
|
+
step(chalk, false, 'Secondary provider configured', 'contact support to enable secondary dispatch');
|
|
104
|
+
console.log(chalk.red('\n ✗ Secondary dispatch provider is not configured\n'));
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const tier = PROVIDER_TO_TIER[providerKey] ?? '1';
|
|
111
|
+
const tierLabel = tier === '1' ? 'tier 1 (managed routing)' : 'tier 2 (marketplace routing)';
|
|
112
|
+
|
|
113
|
+
console.log(chalk.bold('\n⚡ Running end-to-end test\n'));
|
|
114
|
+
console.log(chalk.dim(` Command: ${TEST_COMMAND.join(' ')}`));
|
|
115
|
+
console.log(chalk.dim(` Routing: ${tier === '1' ? 'tier 1 — managed provider routing' : 'tier 2 — marketplace routing'}`));
|
|
116
|
+
console.log(chalk.dim(` Budget: max $${TEST_MAX_PRICE.toFixed(2)}/hr · 2 minute cap (~$0.05 max)`));
|
|
117
|
+
console.log();
|
|
118
|
+
|
|
119
|
+
const rcptId = generateReceiptId();
|
|
120
|
+
let depId;
|
|
121
|
+
|
|
122
|
+
// ── 1. Provision ─────────────────────────────────────────────────────────
|
|
123
|
+
process.stdout.write(chalk.dim(` Provisioning GPU (${tierLabel})...\n`));
|
|
124
|
+
let dep;
|
|
125
|
+
const baseBody = {
|
|
126
|
+
command: TEST_COMMAND,
|
|
127
|
+
image: TEST_IMAGE,
|
|
128
|
+
gpu: 'auto',
|
|
129
|
+
max_price_per_hour: TEST_MAX_PRICE,
|
|
130
|
+
};
|
|
131
|
+
try {
|
|
132
|
+
dep = await callApi('/run', {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
apiKey: config.apiKey,
|
|
135
|
+
baseUrl: config.baseUrl,
|
|
136
|
+
body: { ...baseBody, tier },
|
|
137
|
+
});
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (err.errorData?.code === 'NO_CAPACITY_MATCH' && tier === '1' && !flags.noTierFallback) {
|
|
140
|
+
process.stdout.write('\n');
|
|
141
|
+
process.stdout.write(chalk.dim(' No tier 1 capacity — trying tier 2 marketplace routing...'));
|
|
142
|
+
try {
|
|
143
|
+
dep = await callApi('/run', {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
apiKey: config.apiKey,
|
|
146
|
+
baseUrl: config.baseUrl,
|
|
147
|
+
body: { ...baseBody, tier: '2' },
|
|
148
|
+
});
|
|
149
|
+
} catch (err2) {
|
|
150
|
+
process.stdout.write('\n');
|
|
151
|
+
step(chalk, false, 'Provisioned', err2.message);
|
|
152
|
+
console.log();
|
|
153
|
+
console.error(chalk.red(' Test failed — no GPU capacity available on any provider.\n'));
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
} else if (err.errorData?.code === 'NO_CAPACITY_MATCH' && flags.noTierFallback) {
|
|
157
|
+
process.stdout.write('\n');
|
|
158
|
+
step(chalk, false, 'Provisioned', 'no tier 1 capacity');
|
|
159
|
+
console.log();
|
|
160
|
+
console.error(chalk.red(' Test failed — no tier 1 capacity (strict mode, no tier 2 fallback).\n'));
|
|
161
|
+
process.exit(1);
|
|
162
|
+
} else {
|
|
163
|
+
process.stdout.write('\n');
|
|
164
|
+
step(chalk, false, 'Provisioned', err.message);
|
|
165
|
+
console.log();
|
|
166
|
+
console.error(chalk.red(' Test failed — could not provision GPU.\n'));
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
depId = dep.deployment_id;
|
|
171
|
+
process.stdout.write('\n');
|
|
172
|
+
step(chalk, true, 'Provisioned', `${dep.deployment_id} on ${dep.gpu_type}`);
|
|
173
|
+
|
|
174
|
+
// ── 2. Container started ─────────────────────────────────────────────────
|
|
175
|
+
process.stdout.write(chalk.dim(' Waiting for container to start...'));
|
|
176
|
+
const started = await pollStatus(
|
|
177
|
+
config, depId,
|
|
178
|
+
// Modal serverless jobs may skip straight to succeeded when the callback fires.
|
|
179
|
+
new Set(['running', 'starting', 'succeeded', 'failed', 'stopped', 'completed']),
|
|
180
|
+
TEST_MAX_RUNTIME_MS,
|
|
181
|
+
);
|
|
182
|
+
process.stdout.write('\n');
|
|
183
|
+
|
|
184
|
+
if (!started || started.status === 'failed') {
|
|
185
|
+
step(chalk, false, 'Container started', started?.status ?? 'timeout');
|
|
186
|
+
console.log();
|
|
187
|
+
console.error(chalk.red(' Test failed — container did not start.\n'));
|
|
188
|
+
try { await terminateDeployment(config, depId); } catch { /* best-effort */ }
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
step(chalk, true, 'Container started');
|
|
192
|
+
|
|
193
|
+
// ── 3. Command output ────────────────────────────────────────────────────
|
|
194
|
+
process.stdout.write(chalk.dim(' Checking command output...'));
|
|
195
|
+
const outputResult = await pollOutputOrDone(config, depId, EXPECTED_OUTPUT, 90_000);
|
|
196
|
+
process.stdout.write('\n');
|
|
197
|
+
const gotOutput = outputResult.ok;
|
|
198
|
+
if (gotOutput) {
|
|
199
|
+
step(chalk, true, 'Command printed output');
|
|
200
|
+
} else if (outputResult.done && outputResult.exitCode !== 0) {
|
|
201
|
+
step(chalk, false, 'Command printed output', `exit ${outputResult.exitCode}`);
|
|
202
|
+
} else {
|
|
203
|
+
step(chalk, false, 'Command printed output', 'not found in logs (logs may be buffered)');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── 4. Stop billing ──────────────────────────────────────────────────────
|
|
207
|
+
process.stdout.write(chalk.dim(' Stopping deployment...'));
|
|
208
|
+
let stopped = false;
|
|
209
|
+
try {
|
|
210
|
+
await terminateDeployment(config, depId);
|
|
211
|
+
stopped = true;
|
|
212
|
+
} catch { /* best-effort */ }
|
|
213
|
+
process.stdout.write('\n');
|
|
214
|
+
step(chalk, stopped, 'Billing stopped');
|
|
215
|
+
|
|
216
|
+
// ── 5. Receipt ───────────────────────────────────────────────────────────
|
|
217
|
+
addReceipt({
|
|
218
|
+
receiptId: rcptId,
|
|
219
|
+
action: 'badgr test',
|
|
220
|
+
deploymentId: depId,
|
|
221
|
+
gpu: dep.gpu_type,
|
|
222
|
+
status: gotOutput ? 'test_passed' : 'test_failed',
|
|
223
|
+
createdAt: new Date().toISOString(),
|
|
224
|
+
});
|
|
225
|
+
step(chalk, true, 'Receipt created', rcptId);
|
|
226
|
+
|
|
227
|
+
// ── Summary ──────────────────────────────────────────────────────────────
|
|
228
|
+
console.log();
|
|
229
|
+
const passed = stopped && gotOutput;
|
|
230
|
+
if (passed) {
|
|
231
|
+
console.log(chalk.green(chalk.bold(' ✓ Test passed\n')));
|
|
232
|
+
} else {
|
|
233
|
+
if (!gotOutput) {
|
|
234
|
+
console.error(chalk.red(' Test failed — expected output not found in logs\n'));
|
|
235
|
+
} else {
|
|
236
|
+
console.error(chalk.red(' Test failed — could not stop billing\n'));
|
|
237
|
+
}
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
}
|
package/src/commands/up.js
CHANGED
|
@@ -1,37 +1,31 @@
|
|
|
1
1
|
import { parseSpec, validateSpec, specLines } from '../spec.js';
|
|
2
|
-
import { getRoutePlan } from '../router.js';
|
|
3
2
|
import { requireApiKey } from '../config.js';
|
|
4
3
|
import { generateDeploymentId, generateReceiptId, addDeployment, addReceipt } from '../store.js';
|
|
5
|
-
import { createDeployment } from '../api.js';
|
|
4
|
+
import { createDeployment, callApi } from '../api.js';
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
function printLivePlan(suggestions, gpu, chalk) {
|
|
7
|
+
const { matches = [], alternatives = [] } = suggestions;
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
const { lane1, lane2, cheapestRate, costWithOverhead, canonical } = plan;
|
|
11
|
-
|
|
12
|
-
console.log(chalk.bold(' Lane 1 — Own GPU Hosts'));
|
|
9
|
+
console.log(chalk.bold(' Live availability'));
|
|
13
10
|
console.log(` ${'─'.repeat(40)}`);
|
|
14
|
-
|
|
11
|
+
if (matches.length === 0) {
|
|
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
|
+
}
|
|
15
18
|
console.log();
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
);
|
|
20
|
+
if (alternatives.length > 0) {
|
|
21
|
+
console.log(chalk.bold(' Alternatives if unavailable'));
|
|
22
|
+
console.log(` ${'─'.repeat(40)}`);
|
|
23
|
+
alternatives.slice(0, 3).forEach((a, i) => {
|
|
24
|
+
const diff = a.diff_desc ? chalk.dim(` — ${a.diff_desc}`) : '';
|
|
25
|
+
console.log(` ${i + 1}. ${chalk.cyan(a.gpu)} in ${a.region} $${a.price.toFixed(2)}/hr${diff}`);
|
|
29
26
|
});
|
|
30
27
|
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`));
|
|
33
28
|
}
|
|
34
|
-
console.log();
|
|
35
29
|
console.log(chalk.dim(' Remove --dry-run to provision.'));
|
|
36
30
|
console.log();
|
|
37
31
|
}
|
|
@@ -45,15 +39,27 @@ export async function upCommand(config, args, chalk) {
|
|
|
45
39
|
return;
|
|
46
40
|
}
|
|
47
41
|
|
|
48
|
-
// ── Dry-run: show route plan
|
|
42
|
+
// ── Dry-run: show live route plan from backend ───────────────────────────
|
|
49
43
|
if (spec.dryRun) {
|
|
50
44
|
console.log(chalk.bold('\n🔍 Dry Run — Route Plan\n'));
|
|
51
45
|
console.log(chalk.bold(' Spec'));
|
|
52
46
|
console.log(` ${'─'.repeat(40)}`);
|
|
53
47
|
specLines(spec).forEach(l => console.log(` ${l}`));
|
|
54
48
|
console.log();
|
|
55
|
-
|
|
56
|
-
|
|
49
|
+
|
|
50
|
+
requireApiKey(config);
|
|
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
|
+
}
|
|
57
63
|
return;
|
|
58
64
|
}
|
|
59
65
|
|
package/src/config.js
CHANGED
|
@@ -6,16 +6,61 @@ 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://aibadgr.com/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
|
+
|
|
13
40
|
export function loadConfig(configFile = CONFIG_FILE) {
|
|
14
|
-
if (!existsSync(configFile))
|
|
41
|
+
if (!existsSync(configFile)) {
|
|
42
|
+
return applyEnvOverrides({ ...DEFAULTS });
|
|
43
|
+
}
|
|
15
44
|
try {
|
|
16
|
-
|
|
45
|
+
const parsed = JSON.parse(readFileSync(configFile, 'utf8'));
|
|
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;
|
|
17
62
|
} catch {
|
|
18
|
-
return { ...DEFAULTS };
|
|
63
|
+
return applyEnvOverrides({ ...DEFAULTS });
|
|
19
64
|
}
|
|
20
65
|
}
|
|
21
66
|
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified CLI error catalog — single source of truth for all user-facing errors.
|
|
3
|
+
*
|
|
4
|
+
* Each entry defines:
|
|
5
|
+
* message — what happened (user-safe, no provider/host details)
|
|
6
|
+
* billing — whether billing started, stopped, or is uncertain
|
|
7
|
+
* retried — whether Badgr automatically retried before surfacing this error
|
|
8
|
+
* hint — concrete next step(s) for the user
|
|
9
|
+
* severity — P1/P2/P3/P4 for backend incident tracking
|
|
10
|
+
*
|
|
11
|
+
* Use formatCliError() to render entries as multiline CLI output.
|
|
12
|
+
* Pass internal-only fields (provider names, raw errors) via the `internal`
|
|
13
|
+
* argument — they are only shown when BADGR_DEBUG=1.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const CATALOG = {
|
|
17
|
+
// ── Capacity ──────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
NO_CAPACITY: {
|
|
20
|
+
message: (ctx) =>
|
|
21
|
+
`No ${ctx.gpu || 'GPU'} available${ctx.region ? ` in ${ctx.region}` : ''} right now.`,
|
|
22
|
+
billing: 'never_started',
|
|
23
|
+
retried: false,
|
|
24
|
+
hint: (ctx) => [
|
|
25
|
+
...(ctx.alternatives?.length
|
|
26
|
+
? [`Closest available: ${ctx.alternatives.slice(0, 2).map(a => a.gpu).join(', ')}`]
|
|
27
|
+
: []),
|
|
28
|
+
ctx.region ? 'Remove --region to search globally.' : null,
|
|
29
|
+
'Run `badgr capacity` to see live availability.',
|
|
30
|
+
].filter(Boolean),
|
|
31
|
+
severity: 'P3',
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
STALE_CAPACITY: {
|
|
35
|
+
message: () => 'A GPU slot appeared available but could not be reserved (stale capacity).',
|
|
36
|
+
billing: 'never_started',
|
|
37
|
+
retried: true,
|
|
38
|
+
hint: () => ['Please try again shortly.'],
|
|
39
|
+
severity: 'P3',
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
PROVISIONING_TIMEOUT: {
|
|
43
|
+
message: () => 'GPU provisioning timed out.',
|
|
44
|
+
billing: 'never_started',
|
|
45
|
+
retried: false,
|
|
46
|
+
hint: () => [
|
|
47
|
+
'Please try again.',
|
|
48
|
+
'If this repeats, try a different --gpu type or --region.',
|
|
49
|
+
],
|
|
50
|
+
severity: 'P2',
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
PROVISIONING_FAILED: {
|
|
54
|
+
message: (ctx) =>
|
|
55
|
+
ctx.failure_category === 'compat_failure'
|
|
56
|
+
? 'GPU/CUDA driver incompatibility — the container requires a CUDA version this GPU does not support.'
|
|
57
|
+
: 'GPU was reserved but the container failed to start.',
|
|
58
|
+
billing: 'never_started',
|
|
59
|
+
retried: true,
|
|
60
|
+
hint: (ctx) =>
|
|
61
|
+
ctx.failure_category === 'compat_failure'
|
|
62
|
+
? ['Try a different base image or a different --gpu type.']
|
|
63
|
+
: ['Badgr retried once. Please try again, or try a different --gpu type.'],
|
|
64
|
+
severity: 'P2',
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
// ── Auth / billing ────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
AUTH_FAILED: {
|
|
70
|
+
message: () => 'Invalid or expired API key.',
|
|
71
|
+
billing: 'never_started',
|
|
72
|
+
retried: false,
|
|
73
|
+
hint: () => ['Run `badgr login` to set a new key.'],
|
|
74
|
+
severity: 'P3',
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
BILLING_INSUFFICIENT: {
|
|
78
|
+
message: (ctx) => {
|
|
79
|
+
let msg = 'Insufficient balance.';
|
|
80
|
+
if (ctx.balance_usd != null) msg += ` Balance: $${Number(ctx.balance_usd).toFixed(2)}.`;
|
|
81
|
+
if (ctx.required_usd != null) msg += ` Required: $${Number(ctx.required_usd).toFixed(2)}.`;
|
|
82
|
+
return msg;
|
|
83
|
+
},
|
|
84
|
+
billing: 'never_started',
|
|
85
|
+
retried: false,
|
|
86
|
+
hint: (ctx) => [`Add balance: ${ctx.topup_url || 'https://aibadgr.com/dashboard#billing'}`],
|
|
87
|
+
severity: 'P3',
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
// ── Input validation ──────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
INVALID_GPU: {
|
|
93
|
+
message: (ctx) => `GPU type '${ctx.gpu || 'unknown'}' is not recognized.`,
|
|
94
|
+
billing: 'never_started',
|
|
95
|
+
retried: false,
|
|
96
|
+
hint: () => ['Run `badgr gpus` to see available GPU types.'],
|
|
97
|
+
severity: 'P4',
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
MAX_COST_TOO_LOW: {
|
|
101
|
+
message: (ctx) =>
|
|
102
|
+
`--max-cost $${Number(ctx.maxCost || 0).toFixed(2)} is too low to start a job.`,
|
|
103
|
+
billing: 'never_started',
|
|
104
|
+
retried: false,
|
|
105
|
+
hint: () => [
|
|
106
|
+
'GPU jobs accrue cost from the moment a machine is reserved.',
|
|
107
|
+
'Use --max-cost 1.00 or higher.',
|
|
108
|
+
],
|
|
109
|
+
severity: 'P4',
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
INVALID_MODEL: {
|
|
113
|
+
message: (ctx) =>
|
|
114
|
+
ctx.server_message ||
|
|
115
|
+
`Model '${ctx.model || 'unknown'}' not found or inaccessible on HuggingFace.`,
|
|
116
|
+
billing: 'never_started',
|
|
117
|
+
retried: false,
|
|
118
|
+
hint: () => [
|
|
119
|
+
'Check the model ID at huggingface.co.',
|
|
120
|
+
'For private or gated models, set HF_TOKEN in your environment.',
|
|
121
|
+
],
|
|
122
|
+
severity: 'P4',
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
INSUFFICIENT_VRAM: {
|
|
126
|
+
message: (ctx) =>
|
|
127
|
+
ctx.server_message ||
|
|
128
|
+
`GPU '${ctx.gpu || 'unknown'}' does not have enough VRAM for this model.`,
|
|
129
|
+
billing: 'never_started',
|
|
130
|
+
retried: false,
|
|
131
|
+
hint: () => ['Try --gpu L40S, --gpu A100, or --gpu H100 for larger models.'],
|
|
132
|
+
severity: 'P4',
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
// ── Job / execution ───────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
JOB_FAILED: {
|
|
138
|
+
message: (ctx) => `Job exited with code ${ctx.exitCode ?? 'unknown'}.`,
|
|
139
|
+
billing: 'stopped',
|
|
140
|
+
retried: false,
|
|
141
|
+
hint: (ctx) => [`Check logs: badgr logs ${ctx.deploymentId || '<id>'}`],
|
|
142
|
+
severity: 'P4',
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
JOB_INFRASTRUCTURE_FAILURE: {
|
|
146
|
+
message: () => 'Container failed to start (infrastructure error — not your code).',
|
|
147
|
+
billing: 'never_started',
|
|
148
|
+
retried: false,
|
|
149
|
+
hint: (ctx) => [
|
|
150
|
+
'The backend retried automatically. All attempts failed.',
|
|
151
|
+
'Contact support with your receipt ID for a refund.',
|
|
152
|
+
`Receipt: ${ctx.receiptId || 'run `badgr receipts`'}`,
|
|
153
|
+
],
|
|
154
|
+
severity: 'P2',
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
HEARTBEAT_LOST: {
|
|
158
|
+
message: () => 'Lost connection to the running job — cloud machine became unresponsive.',
|
|
159
|
+
billing: 'stopped',
|
|
160
|
+
retried: false,
|
|
161
|
+
hint: (ctx) => [
|
|
162
|
+
'The job was stopped and billing ended.',
|
|
163
|
+
`Receipt: ${ctx.receiptId || 'run `badgr receipts`'} — contact support if you were charged unexpectedly.`,
|
|
164
|
+
],
|
|
165
|
+
severity: 'P2',
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
// ── Serve / endpoint ──────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
HEALTH_CHECK_FAILED: {
|
|
171
|
+
message: (ctx) =>
|
|
172
|
+
`Endpoint at '${ctx.healthPath || '/models'}' did not become healthy.`,
|
|
173
|
+
billing: 'stopped',
|
|
174
|
+
retried: false,
|
|
175
|
+
hint: (ctx) => [
|
|
176
|
+
'The endpoint was terminated to stop billing.',
|
|
177
|
+
ctx.deploymentId ? `Logs: badgr logs ${ctx.deploymentId}` : null,
|
|
178
|
+
'Check your model config or container startup behaviour.',
|
|
179
|
+
].filter(Boolean),
|
|
180
|
+
severity: 'P3',
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
HEALTH_CHECK_DEPLOY_FAILED: {
|
|
184
|
+
message: (ctx) =>
|
|
185
|
+
`Deployment failed during startup: ${ctx.failReason || 'unknown error'}.`,
|
|
186
|
+
billing: 'stopped',
|
|
187
|
+
retried: false,
|
|
188
|
+
hint: (ctx) => [
|
|
189
|
+
ctx.deploymentId ? `Logs: badgr logs ${ctx.deploymentId}` : null,
|
|
190
|
+
].filter(Boolean),
|
|
191
|
+
severity: 'P3',
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
// ── Teardown / receipt ────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
TEARDOWN_FAILED: {
|
|
197
|
+
message: () => 'Could not stop the job — billing may still be running.',
|
|
198
|
+
billing: 'check_receipt',
|
|
199
|
+
retried: true, // terminateDeployment already retries 3×
|
|
200
|
+
hint: (ctx) => [
|
|
201
|
+
`Run: badgr down ${ctx.deploymentId || '<dep-id>'}`,
|
|
202
|
+
'Or visit your dashboard to stop billing manually.',
|
|
203
|
+
`Receipt: ${ctx.receiptId || 'run `badgr receipts`'}`,
|
|
204
|
+
],
|
|
205
|
+
severity: 'P1',
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
RECEIPT_FAILED: {
|
|
209
|
+
message: () => 'Could not record a local receipt for this job.',
|
|
210
|
+
billing: 'check_receipt',
|
|
211
|
+
retried: false,
|
|
212
|
+
hint: () => ['Check `badgr receipts` or your dashboard for billing details.'],
|
|
213
|
+
severity: 'P3',
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
// ── Network / connection ──────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
NETWORK_ERROR: {
|
|
219
|
+
message: (ctx) =>
|
|
220
|
+
`Could not reach the Badgr API (${ctx.url || ctx.baseUrl || 'unknown URL'}).`,
|
|
221
|
+
billing: 'never_started',
|
|
222
|
+
retried: false,
|
|
223
|
+
hint: (ctx) => [
|
|
224
|
+
'Check your internet connection.',
|
|
225
|
+
'Run `badgr config` to verify the API URL.',
|
|
226
|
+
ctx.baseUrl ? `Test: curl ${ctx.baseUrl}/health` : null,
|
|
227
|
+
].filter(Boolean),
|
|
228
|
+
severity: 'P4',
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
CONNECTION_REFUSED: {
|
|
232
|
+
message: (ctx) => `Connection refused at ${ctx.baseUrl || 'the API'}.`,
|
|
233
|
+
billing: 'never_started',
|
|
234
|
+
retried: false,
|
|
235
|
+
hint: () => ['Is the server running? Run `badgr config` to check the API URL.'],
|
|
236
|
+
severity: 'P4',
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
DNS_FAILED: {
|
|
240
|
+
message: (ctx) => `DNS lookup failed for ${ctx.baseUrl || 'the API'}.`,
|
|
241
|
+
billing: 'never_started',
|
|
242
|
+
retried: false,
|
|
243
|
+
hint: () => [
|
|
244
|
+
'Check your internet connection.',
|
|
245
|
+
'Run `badgr config` to verify the API URL is correct.',
|
|
246
|
+
],
|
|
247
|
+
severity: 'P4',
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Human-readable billing status labels.
|
|
252
|
+
const _BILLING_LABEL = {
|
|
253
|
+
never_started: 'Billing: never started.',
|
|
254
|
+
stopped: 'Billing: stopped.',
|
|
255
|
+
check_receipt: 'Billing: check your receipt or dashboard.',
|
|
256
|
+
charged: 'Billing: you were charged.',
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Render a structured error as a multiline CLI string ready for console.error().
|
|
261
|
+
*
|
|
262
|
+
* @param {string} code - Key from CATALOG
|
|
263
|
+
* @param {object} ctx - Context passed to message/hint templates
|
|
264
|
+
* @param {object} chalk - chalk instance (or passthrough mock in tests)
|
|
265
|
+
* @param {object} [internal] - Internal-only fields shown only with BADGR_DEBUG=1
|
|
266
|
+
* @returns {string}
|
|
267
|
+
*/
|
|
268
|
+
export function formatCliError(code, ctx = {}, chalk, internal = {}) {
|
|
269
|
+
const entry = CATALOG[code];
|
|
270
|
+
if (!entry) {
|
|
271
|
+
return chalk.red(`\n ✗ ${code} — An unexpected error occurred.\n`) +
|
|
272
|
+
chalk.dim(' Run BADGR_DEBUG=1 … for a full trace.\n');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const msg = typeof entry.message === 'function' ? entry.message(ctx) : entry.message;
|
|
276
|
+
const hints = (typeof entry.hint === 'function' ? entry.hint(ctx) : entry.hint) ?? [];
|
|
277
|
+
const billing = _BILLING_LABEL[entry.billing] ?? '';
|
|
278
|
+
const debug = process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true';
|
|
279
|
+
|
|
280
|
+
const lines = [chalk.red(`\n ✗ ${code} — ${msg}`)];
|
|
281
|
+
if (entry.retried) lines.push(chalk.dim(' Badgr retried automatically.'));
|
|
282
|
+
if (billing) lines.push(chalk.dim(` ${billing}`));
|
|
283
|
+
|
|
284
|
+
if (hints.length) {
|
|
285
|
+
lines.push('');
|
|
286
|
+
for (const h of hints) lines.push(chalk.dim(` → ${h}`));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (debug && Object.keys(internal).length > 0) {
|
|
290
|
+
lines.push('');
|
|
291
|
+
lines.push(chalk.dim(' [debug]'));
|
|
292
|
+
for (const [k, v] of Object.entries(internal)) {
|
|
293
|
+
if (v != null) lines.push(chalk.dim(` ${k}: ${String(v)}`));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
lines.push('');
|
|
298
|
+
return lines.join('\n');
|
|
299
|
+
}
|