badgr-cli 1.0.31 → 1.0.32
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 +37 -18
- package/package.json +12 -4
- 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 +19 -4
- package/src/commands/run.js +548 -90
- package/src/commands/serve.js +284 -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/fallback.js +179 -0
- package/src/router.js +16 -73
- package/src/store.js +10 -1
- package/tests/commands.test.js +234 -2
- package/tests/config.test.js +24 -1
- package/tests/router.test.js +9 -68
- package/tests/run-lifecycle.test.js +498 -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/fallback.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import readline from 'readline';
|
|
2
|
+
|
|
3
|
+
// ── Shared routing helpers ────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/** Rates above this threshold trigger a visible warning when no --max-cost is set. */
|
|
6
|
+
export const HIGH_RATE_THRESHOLD = 3.00;
|
|
7
|
+
|
|
8
|
+
/** Normalise --tier flag variants to '1' or '2'. */
|
|
9
|
+
export function normalizeTier(tier) {
|
|
10
|
+
return (tier === '2' || tier === 'tier2' || tier === 'tier-2') ? '2' : (tier || '1');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Sentinel error thrown when callWithFallback cannot provision capacity.
|
|
15
|
+
* The message is already formatted for display; callers should print it and exit.
|
|
16
|
+
*/
|
|
17
|
+
export class CapacityError extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'CapacityError';
|
|
21
|
+
this.isCapacityError = true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Call an API endpoint with automatic tier-2 expansion on NO_CAPACITY_MATCH.
|
|
27
|
+
* Returns the deployment object on success.
|
|
28
|
+
* Throws CapacityError (pre-formatted for display) on unrecoverable failure.
|
|
29
|
+
* Re-throws payment errors (err.isPaymentRequired) for callers to handle.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} endpoint - '/run' or '/serve'
|
|
32
|
+
* @param {object} callOpts - { apiKey, baseUrl }
|
|
33
|
+
* @param {function} buildBody - (tierOverride?) => body object
|
|
34
|
+
* @param {string} effectiveTier
|
|
35
|
+
* @param {object} chalk
|
|
36
|
+
* @param {object} labels - { thing: 'job'|'endpoint', cmd: 'badgr run'|'badgr serve' }
|
|
37
|
+
* @param {object} [opts]
|
|
38
|
+
* @param {boolean} [opts.allowTier2Fallback=true] - set false to disable tier-2 expansion
|
|
39
|
+
*/
|
|
40
|
+
export async function callWithFallback(endpoint, callOpts, buildBody, effectiveTier, chalk, labels, opts = {}) {
|
|
41
|
+
const { callApi } = await import('./api.js');
|
|
42
|
+
const thing = labels?.thing ?? 'job';
|
|
43
|
+
const cmd = labels?.cmd ?? 'badgr run';
|
|
44
|
+
const allowTier2Fallback = opts.allowTier2Fallback !== false; // default true
|
|
45
|
+
|
|
46
|
+
async function attempt(body) {
|
|
47
|
+
return callApi(endpoint, { method: 'POST', ...callOpts, body, timeoutMs: 30_000 });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function buildCapacityError(err, isFallback) {
|
|
51
|
+
const d = err.errorData;
|
|
52
|
+
let msg;
|
|
53
|
+
if (d?.code === 'NO_CAPACITY_MATCH') {
|
|
54
|
+
msg = chalk.red('\n ✗ No suitable GPU capacity available right now.\n') +
|
|
55
|
+
chalk.dim(' Try `badgr capacity` to see what\'s available, or try again shortly.');
|
|
56
|
+
} else if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
|
|
57
|
+
if (d?.low_cost_provider_failed) {
|
|
58
|
+
msg = chalk.red(`\n ✗ No suitable capacity available right now. Try again shortly.\n`);
|
|
59
|
+
} else {
|
|
60
|
+
msg = chalk.red(`\n ✗ Capacity found but ${thing} failed to start. Please try again.\n`);
|
|
61
|
+
if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
|
|
62
|
+
if (d?.debug_error) msg += chalk.dim(` Detail: ${d.debug_error}`);
|
|
63
|
+
} else {
|
|
64
|
+
msg += chalk.dim(` Run BADGR_DEBUG=1 ${cmd} … for a full trace.\n`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
msg = chalk.red(`\n ✗ Could not start ${thing}: ${err.message}\n`) +
|
|
69
|
+
chalk.dim(` Run BADGR_DEBUG=1 ${cmd} … for a full trace.`);
|
|
70
|
+
if (!isFallback) msg += '\n' + chalk.dim(` Check config: badgr config\n`);
|
|
71
|
+
}
|
|
72
|
+
return new CapacityError(msg);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
return await attempt(buildBody());
|
|
77
|
+
} catch (err) {
|
|
78
|
+
const d = err.errorData;
|
|
79
|
+
|
|
80
|
+
if (d?.code === 'NO_CAPACITY_MATCH' && effectiveTier !== '2' && allowTier2Fallback) {
|
|
81
|
+
console.log(chalk.dim('\n Primary capacity unavailable — expanding search...\n'));
|
|
82
|
+
try {
|
|
83
|
+
return await attempt(buildBody('2'));
|
|
84
|
+
} catch (err2) {
|
|
85
|
+
throw buildCapacityError(err2, true);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (err.isPaymentRequired) throw err; // let caller handle payment errors
|
|
90
|
+
throw buildCapacityError(err, false);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── GPU fallback prompt ───────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
// GPU descriptions for display only — no scoring logic lives here.
|
|
97
|
+
const GPU_DISPLAY = {
|
|
98
|
+
RTX_3080: { desc: 'Dev, light inference' },
|
|
99
|
+
RTX_3090: { desc: 'Dev, inference' },
|
|
100
|
+
RTX_4080: { desc: 'Inference and dev workloads' },
|
|
101
|
+
RTX_4090: { desc: 'Inference, training, dev' },
|
|
102
|
+
L40S: { desc: 'Inference, vLLM, batch jobs' },
|
|
103
|
+
A6000: { desc: 'Training, large models, inference' },
|
|
104
|
+
A100: { desc: 'Large-scale training and inference' },
|
|
105
|
+
H100: { desc: 'Large model training, best throughput' },
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Sort alternatives for display.
|
|
110
|
+
*/
|
|
111
|
+
export function rankAlternatives(requestedGpu, alternatives, mode = 'closest') {
|
|
112
|
+
if (!alternatives || alternatives.length === 0) return [];
|
|
113
|
+
if (mode === 'cheapest') return [...alternatives].sort((a, b) => a.price - b.price);
|
|
114
|
+
if (alternatives.some(a => a.rank != null)) {
|
|
115
|
+
return [...alternatives].sort((a, b) => (a.rank ?? 999) - (b.rank ?? 999));
|
|
116
|
+
}
|
|
117
|
+
return [...alternatives].sort((a, b) => a.price - b.price);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* One-line diff for display.
|
|
122
|
+
*/
|
|
123
|
+
export function diffDescription(requestedGpu, altGpu, alt = {}) {
|
|
124
|
+
return alt.diff_desc || '';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function ask(prompt) {
|
|
128
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
129
|
+
return new Promise(resolve => rl.question(prompt, ans => { rl.close(); resolve(ans.trim()); }));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Show the interactive fallback prompt and return the chosen alternative or null.
|
|
134
|
+
* Non-TTY environments auto-select the top-ranked option.
|
|
135
|
+
*/
|
|
136
|
+
export async function promptFallback(requestedGpu, ranked, chalk) {
|
|
137
|
+
if (ranked.length === 0) return null;
|
|
138
|
+
|
|
139
|
+
const top = ranked[0];
|
|
140
|
+
const others = ranked.slice(1, 4);
|
|
141
|
+
const topDisp = GPU_DISPLAY[top.gpu];
|
|
142
|
+
|
|
143
|
+
console.log(chalk.yellow(`\n ${requestedGpu} isn't available right now.\n`));
|
|
144
|
+
console.log(chalk.bold(' Closest match:'));
|
|
145
|
+
console.log(` ${chalk.cyan(top.gpu)} in ${top.region}`);
|
|
146
|
+
console.log(` ${chalk.green('$' + top.price.toFixed(2) + '/hr')} estimated price`);
|
|
147
|
+
if (top.diff_desc) console.log(` ${chalk.dim(top.diff_desc)}`);
|
|
148
|
+
else if (topDisp?.desc) console.log(` ${topDisp.desc}`);
|
|
149
|
+
console.log(chalk.dim(' (availability estimated from market data — not pre-verified)'));
|
|
150
|
+
console.log();
|
|
151
|
+
|
|
152
|
+
if (!process.stdin.isTTY) {
|
|
153
|
+
console.log(chalk.dim(` Auto-selecting ${top.gpu} (non-interactive).`));
|
|
154
|
+
return top;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
console.log(` Press ${chalk.bold('Enter')} to run on ${chalk.cyan(top.gpu)}`);
|
|
158
|
+
if (others.length > 0) {
|
|
159
|
+
console.log(' or type:');
|
|
160
|
+
for (const [i, alt] of others.entries()) {
|
|
161
|
+
const disp = GPU_DISPLAY[alt.gpu];
|
|
162
|
+
const hint = disp ? disp.desc.split(',')[0].toLowerCase() : '';
|
|
163
|
+
console.log(` ${chalk.bold(String(i + 1))} = ${alt.gpu}${hint ? ', ' + hint : ''}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
console.log(` ${chalk.bold('q')} = cancel`);
|
|
167
|
+
console.log();
|
|
168
|
+
|
|
169
|
+
const answer = await ask(' > ');
|
|
170
|
+
|
|
171
|
+
if (answer === '') return top;
|
|
172
|
+
if (answer.toLowerCase() === 'q') return null;
|
|
173
|
+
|
|
174
|
+
const idx = parseInt(answer, 10);
|
|
175
|
+
if (!isNaN(idx) && idx >= 1 && idx <= others.length) return others[idx - 1];
|
|
176
|
+
|
|
177
|
+
console.log(chalk.dim(` Unrecognised input — using ${top.gpu}.`));
|
|
178
|
+
return top;
|
|
179
|
+
}
|