badgr-cli 1.0.25 ā 1.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/models.js +25 -6
- package/src/commands/run.js +53 -123
- package/src/commands/serve.js +1 -1
- package/src/commands/test-run.js +45 -13
- package/src/router.js +16 -12
- package/tests/commands.test.js +3 -32
package/package.json
CHANGED
package/src/commands/models.js
CHANGED
|
@@ -1,19 +1,38 @@
|
|
|
1
|
-
import { listModels } from '../api.js';
|
|
1
|
+
import { listModels, callApi } from '../api.js';
|
|
2
2
|
import { listAll } from '../router.js';
|
|
3
3
|
|
|
4
4
|
export async function modelsCommand(config, chalk) {
|
|
5
|
-
const gpus = listAll();
|
|
6
|
-
|
|
7
5
|
console.log(chalk.bold('\nš¦ GPU Options (cheapest first)\n'));
|
|
6
|
+
|
|
7
|
+
let gpus = null;
|
|
8
|
+
if (config.apiKey) {
|
|
9
|
+
try {
|
|
10
|
+
const data = await callApi('/gpus', { apiKey: config.apiKey, baseUrl: config.baseUrl });
|
|
11
|
+
if (data?.gpus?.length) {
|
|
12
|
+
gpus = data.gpus.map(g => ({
|
|
13
|
+
id: g.id,
|
|
14
|
+
name: g.name,
|
|
15
|
+
vramGb: g.vram_gb,
|
|
16
|
+
ratePerHour: g.rate_per_hour,
|
|
17
|
+
})).sort((a, b) => a.ratePerHour - b.ratePerHour);
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
// fallback to local catalog on error
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!gpus) gpus = listAll();
|
|
25
|
+
|
|
8
26
|
console.log(
|
|
9
|
-
` ${'ID'.padEnd(14)} ${'Name'.padEnd(24)} ${'VRAM'.padEnd(8)} Rate/hr`
|
|
27
|
+
` ${'ID'.padEnd(14)} ${'Name'.padEnd(24)} ${'VRAM'.padEnd(8)} Rate/hr (indicative)`
|
|
10
28
|
);
|
|
11
|
-
console.log(` ${'ā'.repeat(
|
|
29
|
+
console.log(` ${'ā'.repeat(68)}`);
|
|
12
30
|
gpus.forEach(g => {
|
|
13
31
|
console.log(
|
|
14
|
-
` ${chalk.cyan(g.id.padEnd(14))} ${g.name.padEnd(24)} ${`${g.vramGb}GB`.padEnd(8)}
|
|
32
|
+
` ${chalk.cyan(g.id.padEnd(14))} ${g.name.padEnd(24)} ${`${g.vramGb}GB`.padEnd(8)} ~$${g.ratePerHour.toFixed(2)}`
|
|
15
33
|
);
|
|
16
34
|
});
|
|
35
|
+
console.log(chalk.dim(' Actual billing is set at job start ā use `badgr run` to see live rates.'));
|
|
17
36
|
|
|
18
37
|
console.log(chalk.bold('\nš¤ LLM Models\n'));
|
|
19
38
|
if (!config.apiKey) {
|
package/src/commands/run.js
CHANGED
|
@@ -81,18 +81,36 @@ function renderStatusBar(chalk, { elapsedMs, ratePerHour, gpuUtil, cpuUtil, maxR
|
|
|
81
81
|
return chalk.dim(' ' + parts.join(' ⢠'));
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
// Wait for status to leave 'starting'
|
|
84
|
+
// Wait for status to leave 'starting'/'queued'/'provisioning'.
|
|
85
|
+
// This covers image pull time ā runtime limit does NOT start until this returns.
|
|
85
86
|
// Returns the final dep object with status 'running' or 'failed'.
|
|
86
87
|
async function waitForRunning(config, depId, chalk) {
|
|
87
|
-
const POLL_MS
|
|
88
|
-
const TIMEOUT_MS
|
|
89
|
-
const startMs
|
|
90
|
-
|
|
88
|
+
const POLL_MS = 3000;
|
|
89
|
+
const TIMEOUT_MS = 5 * 60 * 1000; // 5 min startup grace (image pull + container init)
|
|
90
|
+
const startMs = Date.now();
|
|
91
|
+
const PHASES = [
|
|
92
|
+
{ afterMs: 0, label: ' Starting container' },
|
|
93
|
+
{ afterMs: 15000, label: ' Pulling image' },
|
|
94
|
+
{ afterMs: 60000, label: ' Starting container' },
|
|
95
|
+
{ afterMs: 180000, label: ' Running command' },
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
let lastPhaseIdx = -1;
|
|
99
|
+
const STARTUP_STATES = new Set(['queued', 'provisioning', 'starting']);
|
|
91
100
|
|
|
92
|
-
process.stdout.write(chalk.dim(' Waiting for container to start'));
|
|
93
101
|
const ticker = setInterval(() => {
|
|
102
|
+
const elapsed = Date.now() - startMs;
|
|
103
|
+
// Find the latest phase whose afterMs has been passed
|
|
104
|
+
let phaseIdx = 0;
|
|
105
|
+
for (let i = 0; i < PHASES.length; i++) {
|
|
106
|
+
if (elapsed >= PHASES[i].afterMs) phaseIdx = i;
|
|
107
|
+
}
|
|
108
|
+
if (phaseIdx !== lastPhaseIdx) {
|
|
109
|
+
process.stdout.write('\r\x1b[2K');
|
|
110
|
+
process.stdout.write(chalk.dim(PHASES[phaseIdx].label));
|
|
111
|
+
lastPhaseIdx = phaseIdx;
|
|
112
|
+
}
|
|
94
113
|
process.stdout.write('.');
|
|
95
|
-
dots++;
|
|
96
114
|
}, 1000);
|
|
97
115
|
|
|
98
116
|
try {
|
|
@@ -102,23 +120,22 @@ async function waitForRunning(config, depId, chalk) {
|
|
|
102
120
|
apiKey: config.apiKey,
|
|
103
121
|
baseUrl: config.baseUrl,
|
|
104
122
|
});
|
|
105
|
-
if (dep.status
|
|
106
|
-
clearInterval(ticker);
|
|
123
|
+
if (!STARTUP_STATES.has(dep.status)) {
|
|
107
124
|
process.stdout.write('\n');
|
|
108
125
|
return dep;
|
|
109
126
|
}
|
|
110
127
|
}
|
|
111
128
|
} finally {
|
|
112
129
|
clearInterval(ticker);
|
|
113
|
-
|
|
130
|
+
process.stdout.write('\n');
|
|
114
131
|
}
|
|
115
132
|
|
|
116
|
-
// Timed out
|
|
133
|
+
// Timed out ā return last known state
|
|
117
134
|
return await callApi(`/deployments/${depId}`, { apiKey: config.apiKey, baseUrl: config.baseUrl });
|
|
118
135
|
}
|
|
119
136
|
|
|
120
137
|
async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost = null, ratePerHour = 0, onTeardown }) {
|
|
121
|
-
const TERMINAL = new Set(['stopped', 'failed', 'completed']);
|
|
138
|
+
const TERMINAL = new Set(['stopped', 'failed', 'completed', 'succeeded']);
|
|
122
139
|
const POLL_MS = 4000;
|
|
123
140
|
let seenContent = new Set(); // track by content, not index, to avoid reprinting stale lines
|
|
124
141
|
let lastStatus = '';
|
|
@@ -268,63 +285,6 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
268
285
|
}
|
|
269
286
|
}
|
|
270
287
|
|
|
271
|
-
/**
|
|
272
|
-
* Infer a workload profile name from the command the user wants to run.
|
|
273
|
-
* The backend uses this to enforce a VRAM floor when picking a GPU.
|
|
274
|
-
*
|
|
275
|
-
* Note: \b word boundaries are intentionally avoided for keyword checks
|
|
276
|
-
* because keywords commonly appear inside filenames joined by underscores
|
|
277
|
-
* (e.g. lora_train.py, run_sdxl.py) where `_` is a word character.
|
|
278
|
-
*/
|
|
279
|
-
export function inferWorkload(command) {
|
|
280
|
-
if (!command || command.length === 0) return 'general';
|
|
281
|
-
|
|
282
|
-
const cmd = command.join(' ').toLowerCase();
|
|
283
|
-
|
|
284
|
-
// Trivial one-liner / smoke test
|
|
285
|
-
if (/print\s*\(|['"]hello/.test(cmd) && cmd.length < 80) return 'smoke_test';
|
|
286
|
-
|
|
287
|
-
// LoRA / QLoRA / PEFT fine-tuning
|
|
288
|
-
if (/lora|qlora|finetune|fine[_-]tun|peft/.test(cmd)) return 'lora_finetune';
|
|
289
|
-
|
|
290
|
-
// Diffusion / image generation
|
|
291
|
-
if (/diffusion|stable.?diff|sdxl|sd.?xl|comfyui|a1111|invoke|kohya/.test(cmd)) return 'image_gen';
|
|
292
|
-
|
|
293
|
-
// vLLM / TGI / inference server
|
|
294
|
-
if (/vllm|[^a-z]tgi[^a-z]|^tgi\b|tgi$|text.generation.inference/.test(cmd)) return 'inference_small';
|
|
295
|
-
|
|
296
|
-
// Explicit training script
|
|
297
|
-
if (/\btrain\.py\b/.test(cmd)) return 'lora_finetune';
|
|
298
|
-
|
|
299
|
-
return 'general';
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const WORKLOAD_LABELS = {
|
|
303
|
-
smoke_test: 'smoke test',
|
|
304
|
-
general: 'GPU job',
|
|
305
|
-
lora_finetune: 'fine-tuning (40GB+ VRAM)',
|
|
306
|
-
image_gen: 'image generation',
|
|
307
|
-
inference_small: 'inference (7Bā8B model)',
|
|
308
|
-
inference_medium: 'inference (30Bā34B model)',
|
|
309
|
-
inference_large: 'inference (70B+ model)',
|
|
310
|
-
};
|
|
311
|
-
|
|
312
|
-
// Ask the backend for the best available GPU for a given workload.
|
|
313
|
-
// Routes by: workload ā min VRAM ā provider tier ā cheapest match.
|
|
314
|
-
// Returns { gpu, region, price, workload, workload_desc } or null when nothing is available.
|
|
315
|
-
async function findAutoGpu(config, chalk, tier = '1', workload = 'general') {
|
|
316
|
-
try {
|
|
317
|
-
const params = new URLSearchParams({ max_price: '10', tier, workload });
|
|
318
|
-
return await callApi(`/capacity/auto?${params}`, {
|
|
319
|
-
apiKey: config.apiKey,
|
|
320
|
-
baseUrl: config.baseUrl,
|
|
321
|
-
});
|
|
322
|
-
} catch (err) {
|
|
323
|
-
if (err.errorData?.code === 'NO_CAPACITY_MATCH') return null;
|
|
324
|
-
throw err;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
288
|
function askConfirm(prompt) {
|
|
329
289
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
330
290
|
return new Promise(resolve => rl.question(prompt, ans => { rl.close(); resolve(ans.trim()); }));
|
|
@@ -342,7 +302,11 @@ export async function runCommand(config, args, chalk) {
|
|
|
342
302
|
requireApiKey(config);
|
|
343
303
|
|
|
344
304
|
const command = positional.length > 0 ? positional : undefined;
|
|
345
|
-
|
|
305
|
+
// Use alpine for trivial one-liners (7MB vs 50MB ā much faster pull)
|
|
306
|
+
const cmdStr = command ? command.join(' ') : '';
|
|
307
|
+
const isSmoke = cmdStr.length < 80 && /print\s*\(|['"]hello/i.test(cmdStr);
|
|
308
|
+
const inferredImage = isSmoke ? 'python:3.11-alpine' : 'python:3.11-slim';
|
|
309
|
+
const image = flags.image || (command ? inferredImage : undefined);
|
|
346
310
|
const detach = flags.detach || false;
|
|
347
311
|
const fallbackMode = flags.noFallback ? 'none' : (flags.fallback || 'closest');
|
|
348
312
|
const maxRuntimeMs = flags.maxRuntime ? flags.maxRuntime * 60 * 1000 : null;
|
|
@@ -354,54 +318,14 @@ export async function runCommand(config, args, chalk) {
|
|
|
354
318
|
: (flags.tier || '1');
|
|
355
319
|
|
|
356
320
|
// āā Auto GPU selection (no --gpu specified) āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
357
|
-
let gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') :
|
|
358
|
-
let autoRegion = null;
|
|
321
|
+
let gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : 'auto';
|
|
359
322
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
const workloadLabel = WORKLOAD_LABELS[workload] || 'GPU job';
|
|
363
|
-
|
|
364
|
-
if (!gpu) {
|
|
365
|
-
console.log(chalk.bold(`\nā” Running ${workloadLabel}\n`));
|
|
323
|
+
if (gpu === 'auto') {
|
|
324
|
+
console.log(chalk.bold('\nā” Running GPU job\n'));
|
|
366
325
|
if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
|
|
367
326
|
if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
|
|
368
327
|
if (effectiveTier === '2') console.log(` ${chalk.dim('(tier 2 ā marketplace routing)')}`)
|
|
369
328
|
console.log();
|
|
370
|
-
process.stdout.write(chalk.dim(' Finding best GPU...'));
|
|
371
|
-
|
|
372
|
-
let best;
|
|
373
|
-
try {
|
|
374
|
-
best = await findAutoGpu(config, chalk, effectiveTier, workload);
|
|
375
|
-
} catch (err) {
|
|
376
|
-
process.stdout.write('\n');
|
|
377
|
-
console.error(chalk.red(`\n ā Could not find GPU capacity: ${err.message}\n`));
|
|
378
|
-
process.exit(1);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
process.stdout.write('\n');
|
|
382
|
-
|
|
383
|
-
if (!best) {
|
|
384
|
-
console.error(chalk.red('\n ā No GPU capacity available right now.\n'));
|
|
385
|
-
console.error(chalk.dim(' Run `badgr capacity` for details, or try again in a few minutes.'));
|
|
386
|
-
process.exit(1);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
const vramNote = best.min_vram_gb ? chalk.dim(` (${best.min_vram_gb}GB VRAM)`) : '';
|
|
390
|
-
console.log(`\n ${chalk.bold('Selected:')} ${chalk.cyan(best.gpu)} in ${best.region} ā ${chalk.green('$' + best.price.toFixed(2) + '/hr')}${vramNote}`);
|
|
391
|
-
console.log();
|
|
392
|
-
|
|
393
|
-
if (effectiveTier === '2' && process.stdin.isTTY) {
|
|
394
|
-
// Budget mode: confirm because the user is being routed to a less reliable provider.
|
|
395
|
-
const answer = await askConfirm(` Press ${chalk.bold('Enter')} to run on tier 2 (marketplace), or ${chalk.bold('q')} to cancel: `);
|
|
396
|
-
if (answer.toLowerCase() === 'q') {
|
|
397
|
-
console.log(chalk.dim('\n Cancelled.\n'));
|
|
398
|
-
process.exit(0);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
gpu = best.gpu;
|
|
403
|
-
autoRegion = best.region;
|
|
404
|
-
console.log();
|
|
405
329
|
} else {
|
|
406
330
|
// Specific GPU requested ā show header
|
|
407
331
|
console.log(chalk.bold('\nā” Running GPU job\n'));
|
|
@@ -419,15 +343,13 @@ export async function runCommand(config, args, chalk) {
|
|
|
419
343
|
}
|
|
420
344
|
}
|
|
421
345
|
|
|
422
|
-
console.log(chalk.dim(' Finding
|
|
346
|
+
console.log(chalk.dim(' Finding reliable capacity...'));
|
|
423
347
|
if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
|
|
424
348
|
console.log(chalk.dim(` API: ${config.baseUrl}`));
|
|
425
349
|
}
|
|
426
350
|
|
|
427
|
-
function buildBody(gpuOverride
|
|
428
|
-
const effectiveRegion =
|
|
429
|
-
?? autoRegion
|
|
430
|
-
?? (flags.region ? flags.region.toUpperCase() : undefined);
|
|
351
|
+
function buildBody(gpuOverride) {
|
|
352
|
+
const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
|
|
431
353
|
return {
|
|
432
354
|
command,
|
|
433
355
|
image,
|
|
@@ -534,6 +456,7 @@ export async function runCommand(config, args, chalk) {
|
|
|
534
456
|
console.log();
|
|
535
457
|
console.log(chalk.bold(` Job ID: ${chalk.cyan(dep.deployment_id)}`));
|
|
536
458
|
console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} Ć ${dep.gpu_count}`);
|
|
459
|
+
if (dep.workload_desc) console.log(` ${chalk.bold('Workload:')} ${dep.workload_desc}`);
|
|
537
460
|
if (dep.tier) console.log(` ${chalk.bold('Tier:')} ${dep.tier}`);
|
|
538
461
|
if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
|
|
539
462
|
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
@@ -544,8 +467,10 @@ export async function runCommand(config, args, chalk) {
|
|
|
544
467
|
return;
|
|
545
468
|
}
|
|
546
469
|
|
|
547
|
-
//
|
|
548
|
-
|
|
470
|
+
// Wait through startup phases (queued ā provisioning ā starting ā running).
|
|
471
|
+
// The max-runtime clock does NOT start until this returns ā image pull time is free.
|
|
472
|
+
const STARTUP_STATES = new Set(['queued', 'provisioning', 'starting']);
|
|
473
|
+
if (STARTUP_STATES.has(dep.status)) {
|
|
549
474
|
console.log();
|
|
550
475
|
dep = await waitForRunning(config, dep.deployment_id, chalk);
|
|
551
476
|
}
|
|
@@ -559,7 +484,7 @@ export async function runCommand(config, args, chalk) {
|
|
|
559
484
|
}
|
|
560
485
|
|
|
561
486
|
const ratePerHour = dep.cost_per_hour || 0;
|
|
562
|
-
console.log(chalk.dim('\n āā
|
|
487
|
+
console.log(chalk.dim('\n āā Running command (Ctrl+C to stop) āāāāāāāāāāāāāāāāāāāāāāāāāāāā\n'));
|
|
563
488
|
|
|
564
489
|
async function teardown(reason) {
|
|
565
490
|
const labels = {
|
|
@@ -621,7 +546,12 @@ export async function runCommand(config, args, chalk) {
|
|
|
621
546
|
}
|
|
622
547
|
console.log();
|
|
623
548
|
process.exit(exitCode ?? 1);
|
|
624
|
-
} else if (finalStatus === 'completed' && (exitCode === 0 || exitCode === null)) {
|
|
625
|
-
|
|
549
|
+
} else if ((finalStatus === 'completed' || finalStatus === 'succeeded') && (exitCode === 0 || exitCode === null)) {
|
|
550
|
+
try {
|
|
551
|
+
await terminateDeployment(config, dep.deployment_id);
|
|
552
|
+
} catch { /* already stopped */ }
|
|
553
|
+
console.log(chalk.green(`\n ā Complete`));
|
|
554
|
+
console.log(chalk.dim(` Billing ended`));
|
|
555
|
+
console.log();
|
|
626
556
|
}
|
|
627
557
|
}
|
package/src/commands/serve.js
CHANGED
|
@@ -84,7 +84,7 @@ export async function serveCommand(config, args, chalk) {
|
|
|
84
84
|
console.log(` ${chalk.bold('GPU:')} ${gpuLabel}`);
|
|
85
85
|
if (effectiveTier === '2') console.log(` ${chalk.dim('(tier 2 ā marketplace routing)')}`);
|
|
86
86
|
console.log();
|
|
87
|
-
process.stdout.write(chalk.dim(' Finding
|
|
87
|
+
process.stdout.write(chalk.dim(' Finding reliable capacity...\n'));
|
|
88
88
|
|
|
89
89
|
const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
|
|
90
90
|
|
package/src/commands/test-run.js
CHANGED
|
@@ -2,11 +2,13 @@ import { requireApiKey } from '../config.js';
|
|
|
2
2
|
import { callApi, terminateDeployment } from '../api.js';
|
|
3
3
|
import { addReceipt, generateReceiptId } from '../store.js';
|
|
4
4
|
|
|
5
|
-
// max $
|
|
6
|
-
const TEST_MAX_PRICE =
|
|
5
|
+
// max $0.80/hr Ć 2 min ā $0.027 total spend cap (smoke / Modal T4 tier)
|
|
6
|
+
const TEST_MAX_PRICE = 0.80;
|
|
7
7
|
const TEST_MAX_RUNTIME_MS = 2 * 60 * 1000;
|
|
8
8
|
const TEST_COMMAND = ['python', '-c', "print('hello from badgr')"];
|
|
9
|
-
|
|
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';
|
|
10
12
|
const EXPECTED_OUTPUT = 'hello from badgr';
|
|
11
13
|
|
|
12
14
|
// --provider flag resolves to a backend tier value.
|
|
@@ -18,6 +20,7 @@ export function parseTestArgs(args) {
|
|
|
18
20
|
let i = 0;
|
|
19
21
|
while (i < args.length) {
|
|
20
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; }
|
|
21
24
|
i++;
|
|
22
25
|
}
|
|
23
26
|
return flags;
|
|
@@ -44,20 +47,36 @@ async function pollStatus(config, depId, targetStatuses, timeoutMs) {
|
|
|
44
47
|
return null;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
async function
|
|
50
|
+
async function pollOutputOrDone(config, depId, expected, timeoutMs) {
|
|
48
51
|
const deadline = Date.now() + timeoutMs;
|
|
49
52
|
while (Date.now() < deadline) {
|
|
50
|
-
await new Promise(r => setTimeout(r,
|
|
53
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
51
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
|
+
}
|
|
52
71
|
const data = await callApi(`/deployments/${depId}/logs`, {
|
|
53
72
|
apiKey: config.apiKey,
|
|
54
73
|
baseUrl: config.baseUrl,
|
|
55
74
|
});
|
|
56
75
|
const lines = data?.logs ?? [];
|
|
57
|
-
if (lines.some(l => l.includes(expected))) return true;
|
|
76
|
+
if (lines.some(l => l.includes(expected))) return { done: true, ok: true, exitCode: 0 };
|
|
58
77
|
} catch { /* retry */ }
|
|
59
78
|
}
|
|
60
|
-
return false;
|
|
79
|
+
return { done: false, ok: false, exitCode: null };
|
|
61
80
|
}
|
|
62
81
|
|
|
63
82
|
export async function testCommand(config, args, chalk) {
|
|
@@ -106,7 +125,7 @@ export async function testCommand(config, args, chalk) {
|
|
|
106
125
|
const baseBody = {
|
|
107
126
|
command: TEST_COMMAND,
|
|
108
127
|
image: TEST_IMAGE,
|
|
109
|
-
gpu: '
|
|
128
|
+
gpu: 'auto',
|
|
110
129
|
max_price_per_hour: TEST_MAX_PRICE,
|
|
111
130
|
};
|
|
112
131
|
try {
|
|
@@ -117,7 +136,7 @@ export async function testCommand(config, args, chalk) {
|
|
|
117
136
|
body: { ...baseBody, tier },
|
|
118
137
|
});
|
|
119
138
|
} catch (err) {
|
|
120
|
-
if (err.errorData?.code === 'NO_CAPACITY_MATCH' && tier === '1') {
|
|
139
|
+
if (err.errorData?.code === 'NO_CAPACITY_MATCH' && tier === '1' && !flags.noTierFallback) {
|
|
121
140
|
process.stdout.write('\n');
|
|
122
141
|
process.stdout.write(chalk.dim(' No tier 1 capacity ā trying tier 2 marketplace routing...'));
|
|
123
142
|
try {
|
|
@@ -134,6 +153,12 @@ export async function testCommand(config, args, chalk) {
|
|
|
134
153
|
console.error(chalk.red(' Test failed ā no GPU capacity available on any provider.\n'));
|
|
135
154
|
process.exit(1);
|
|
136
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);
|
|
137
162
|
} else {
|
|
138
163
|
process.stdout.write('\n');
|
|
139
164
|
step(chalk, false, 'Provisioned', err.message);
|
|
@@ -166,10 +191,13 @@ export async function testCommand(config, args, chalk) {
|
|
|
166
191
|
|
|
167
192
|
// āā 3. Command output āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
168
193
|
process.stdout.write(chalk.dim(' Checking command output...'));
|
|
169
|
-
const
|
|
194
|
+
const outputResult = await pollOutputOrDone(config, depId, EXPECTED_OUTPUT, 90_000);
|
|
170
195
|
process.stdout.write('\n');
|
|
196
|
+
const gotOutput = outputResult.ok;
|
|
171
197
|
if (gotOutput) {
|
|
172
198
|
step(chalk, true, 'Command printed output');
|
|
199
|
+
} else if (outputResult.done && outputResult.exitCode !== 0) {
|
|
200
|
+
step(chalk, false, 'Command printed output', `exit ${outputResult.exitCode}`);
|
|
173
201
|
} else {
|
|
174
202
|
step(chalk, false, 'Command printed output', 'not found in logs (logs may be buffered)');
|
|
175
203
|
}
|
|
@@ -190,18 +218,22 @@ export async function testCommand(config, args, chalk) {
|
|
|
190
218
|
action: 'badgr test',
|
|
191
219
|
deploymentId: depId,
|
|
192
220
|
gpu: dep.gpu_type,
|
|
193
|
-
status: '
|
|
221
|
+
status: gotOutput ? 'test_passed' : 'test_failed',
|
|
194
222
|
createdAt: new Date().toISOString(),
|
|
195
223
|
});
|
|
196
224
|
step(chalk, true, 'Receipt created', rcptId);
|
|
197
225
|
|
|
198
226
|
// āā Summary āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
199
227
|
console.log();
|
|
200
|
-
const passed = stopped;
|
|
228
|
+
const passed = stopped && gotOutput;
|
|
201
229
|
if (passed) {
|
|
202
230
|
console.log(chalk.green(chalk.bold(' ā Test passed\n')));
|
|
203
231
|
} else {
|
|
204
|
-
|
|
232
|
+
if (!gotOutput) {
|
|
233
|
+
console.error(chalk.red(' Test failed ā expected output not found in logs\n'));
|
|
234
|
+
} else {
|
|
235
|
+
console.error(chalk.red(' Test failed ā could not stop billing\n'));
|
|
236
|
+
}
|
|
205
237
|
process.exit(1);
|
|
206
238
|
}
|
|
207
239
|
}
|
package/src/router.js
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GPU catalog ā static
|
|
2
|
+
* GPU catalog ā static display info only (name, VRAM, tags, indicative rate).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* For live
|
|
7
|
-
*
|
|
4
|
+
* ratePerHour values are indicative market averages for reference display.
|
|
5
|
+
* Actual billing is always set by the backend and returned as cost_per_hour
|
|
6
|
+
* on every deployment response. For live rates, use GET /v1/gpus.
|
|
7
|
+
*
|
|
8
|
+
* Provider routing logic lives entirely in the backend.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
export const GPU_CATALOG = [
|
|
11
|
-
{ id: 'rtx-3080', canonical: 'RTX_3080',
|
|
12
|
-
{ id: 'rtx-
|
|
13
|
-
{ id: '
|
|
14
|
-
{ id: '
|
|
15
|
-
{ id: '
|
|
16
|
-
{ id: '
|
|
17
|
-
{ id: '
|
|
12
|
+
{ id: 'rtx-3080', canonical: 'RTX_3080', name: 'NVIDIA RTX 3080', vramGb: 10, ratePerHour: 0.35, tags: ['inference', 'dev'] },
|
|
13
|
+
{ id: 'rtx-3090', canonical: 'RTX_3090', name: 'NVIDIA RTX 3090', vramGb: 24, ratePerHour: 0.60, tags: ['inference', 'dev'] },
|
|
14
|
+
{ id: 'rtx-4090', canonical: 'RTX_4090', name: 'NVIDIA RTX 4090', vramGb: 24, ratePerHour: 1.10, tags: ['inference', 'training', 'dev'] },
|
|
15
|
+
{ id: 'a4000', canonical: 'A4000', name: 'NVIDIA RTX A4000', vramGb: 16, ratePerHour: 0.50, tags: ['inference', 'dev'] },
|
|
16
|
+
{ id: 'a5000', canonical: 'A5000', name: 'NVIDIA RTX A5000', vramGb: 24, ratePerHour: 0.70, tags: ['inference', 'dev'] },
|
|
17
|
+
{ id: 'l40s', canonical: 'L40S', name: 'NVIDIA L40S', vramGb: 48, ratePerHour: 1.40, tags: ['inference', 'training'] },
|
|
18
|
+
{ id: 'a6000', canonical: 'A6000', name: 'NVIDIA RTX A6000', vramGb: 48, ratePerHour: 1.60, tags: ['inference', 'training'] },
|
|
19
|
+
{ id: 'a100-40gb', canonical: 'A100', name: 'NVIDIA A100 40GB', vramGb: 40, ratePerHour: 1.80, tags: ['training', 'inference'] },
|
|
20
|
+
{ id: 'a100-80gb', canonical: 'A100_80GB', name: 'NVIDIA A100 80GB', vramGb: 80, ratePerHour: 2.50, tags: ['training', 'large-model'] },
|
|
21
|
+
{ id: 'h100', canonical: 'H100', name: 'NVIDIA H100 80GB', vramGb: 80, ratePerHour: 3.50, tags: ['training', 'large-model'] },
|
|
18
22
|
];
|
|
19
23
|
|
|
20
24
|
export function findById(id) {
|
package/tests/commands.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { parseRunArgs, classifyFailure
|
|
2
|
+
import { parseRunArgs, classifyFailure } from '../src/commands/run.js';
|
|
3
3
|
import { parseServeArgs } from '../src/commands/serve.js';
|
|
4
4
|
import { testCommand, parseTestArgs } from '../src/commands/test-run.js';
|
|
5
5
|
import { rankAlternatives, diffDescription, promptFallback } from '../src/fallback.js';
|
|
@@ -232,38 +232,9 @@ describe('parseTestArgs', () => {
|
|
|
232
232
|
it('parses --provider secondary', () => {
|
|
233
233
|
expect(parseTestArgs(['--provider', 'secondary'])).toEqual({ provider: 'secondary' });
|
|
234
234
|
});
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
describe('inferWorkload', () => {
|
|
238
|
-
it('returns general for empty command', () => {
|
|
239
|
-
expect(inferWorkload([])).toBe('general');
|
|
240
|
-
expect(inferWorkload(null)).toBe('general');
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
it('returns smoke_test for short print() one-liners', () => {
|
|
244
|
-
expect(inferWorkload(['python', '-c', "print('hello')"])).toBe('smoke_test');
|
|
245
|
-
expect(inferWorkload(['python', '-c', "print('hello from badgr')"])).toBe('smoke_test');
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
it('returns general for a typical script', () => {
|
|
249
|
-
expect(inferWorkload(['python', 'script.py'])).toBe('general');
|
|
250
|
-
expect(inferWorkload(['python', 'run.py', '--epochs', '10'])).toBe('general');
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it('returns lora_finetune for LoRA/fine-tuning keywords', () => {
|
|
254
|
-
expect(inferWorkload(['python', 'lora_train.py'])).toBe('lora_finetune');
|
|
255
|
-
expect(inferWorkload(['python', '-c', 'import peft; finetune()'])).toBe('lora_finetune');
|
|
256
|
-
expect(inferWorkload(['python', 'train.py', '--epochs', '3'])).toBe('lora_finetune');
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it('returns image_gen for diffusion keywords', () => {
|
|
260
|
-
expect(inferWorkload(['python', 'stable_diff.py'])).toBe('image_gen');
|
|
261
|
-
expect(inferWorkload(['python', 'run_sdxl.py'])).toBe('image_gen');
|
|
262
|
-
});
|
|
263
235
|
|
|
264
|
-
it('
|
|
265
|
-
expect(
|
|
266
|
-
expect(inferWorkload(['python', 'serve_tgi.py'])).toBe('inference_small');
|
|
236
|
+
it('parses --no-tier-fallback', () => {
|
|
237
|
+
expect(parseTestArgs(['--no-tier-fallback'])).toEqual({ noTierFallback: true });
|
|
267
238
|
});
|
|
268
239
|
});
|
|
269
240
|
|