badgr-cli 1.1.3 → 1.1.5
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/README.md +176 -13
- package/package.json +2 -1
- package/src/api.js +28 -0
- package/src/badgr.js +3 -3
- package/src/commands/artifacts.js +5 -3
- package/src/commands/batch.js +6 -5
- package/src/commands/billing.js +3 -3
- package/src/commands/capacity.js +2 -2
- package/src/commands/deploy.js +2 -2
- package/src/commands/diagnose.js +576 -82
- package/src/commands/down.js +69 -14
- package/src/commands/embed.js +2 -2
- package/src/commands/heartbeat.js +3 -3
- package/src/commands/job.js +14 -3
- package/src/commands/login.js +4 -1
- package/src/commands/logs.js +35 -18
- package/src/commands/pull.js +4 -3
- package/src/commands/rerun.js +5 -3
- package/src/commands/restart.js +5 -3
- package/src/commands/run.js +5 -23
- package/src/commands/sbatch.js +2 -1
- package/src/commands/serve.js +95 -18
- package/src/commands/shell.js +2 -2
- package/src/commands/status.js +57 -13
- package/src/commands/test-run.js +2 -2
- package/src/commands/train.js +3 -3
- package/src/commands/transcribe.js +2 -2
- package/src/commands/up.js +2 -1
- package/src/config.js +19 -0
- package/src/deploymentLog.js +45 -0
- package/src/errors.js +12 -3
- package/src/fallback.js +55 -32
- package/src/onboarding.js +63 -4
- package/src/progress.js +5 -2
package/src/commands/serve.js
CHANGED
|
@@ -3,7 +3,7 @@ import { callApi, listDeployments } from '../api.js';
|
|
|
3
3
|
import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../store.js';
|
|
4
4
|
import { normalizeTier, callWithFallback } from '../fallback.js';
|
|
5
5
|
import { formatCliError } from '../errors.js';
|
|
6
|
-
import { requireApiKey } from '../config.js';
|
|
6
|
+
import { requireApiKey, webBaseUrl } from '../config.js';
|
|
7
7
|
import { parseEnvFlag } from '../envFlag.js';
|
|
8
8
|
import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides, BLESSED_VLLM_MODELS, isLikelyGatedModel } from '../catalog.js';
|
|
9
9
|
import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass, printCapacityPreview, formatTierLabel } from '../progress.js';
|
|
@@ -65,11 +65,19 @@ function envObjHasHfToken(envList) {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
// Extract a parameter-count-in-billions hint from a model/file name, e.g.
|
|
68
|
-
// "Qwen2.5-0.5B-Instruct" → 0.5, "Llama-3.1-8B-Instruct" → 8, "Mixtral-8x7B" → 56
|
|
68
|
+
// "Qwen2.5-0.5B-Instruct" → 0.5, "Llama-3.1-8B-Instruct" → 8, "Mixtral-8x7B" → 56,
|
|
69
|
+
// "facebook/opt-125m" → 0.125 (an "M" suffix means millions, not billions --
|
|
70
|
+
// omitting this previously meant every sub-1B-parameter model name came back
|
|
71
|
+
// with no size hint at all, silently falling through to the generic 7B-8B
|
|
72
|
+
// default below).
|
|
69
73
|
// Splits into delimiter-bounded segments first so a version number like "2.5"
|
|
70
74
|
// in "Qwen2.5-0.5B" is never mistaken for the param count — only a segment that
|
|
71
|
-
// IS entirely "<digits>b" or "<digits>x<digits>b" counts as a size
|
|
72
|
-
|
|
75
|
+
// IS entirely "<digits>b"/"<digits>m" or "<digits>x<digits>b" counts as a size
|
|
76
|
+
// hint. Mirrors backend/workload_profile.py's own `_extract_params_b` --
|
|
77
|
+
// keep the two in sync; a drift between them means this CLI preview and the
|
|
78
|
+
// backend's real GPU selection could show a different workload class for
|
|
79
|
+
// the same model.
|
|
80
|
+
export function _extractParamsB(name) {
|
|
73
81
|
const s = name.toLowerCase();
|
|
74
82
|
const segments = s.split(/[^a-z0-9.]+/).filter(Boolean);
|
|
75
83
|
for (const seg of segments) {
|
|
@@ -80,16 +88,26 @@ function _extractParamsB(name) {
|
|
|
80
88
|
const m = seg.match(/^(\d+(?:\.\d+)?)b$/);
|
|
81
89
|
if (m) return parseFloat(m[1]);
|
|
82
90
|
}
|
|
91
|
+
for (const seg of segments) {
|
|
92
|
+
const m = seg.match(/^(\d+(?:\.\d+)?)m$/);
|
|
93
|
+
if (m) return parseFloat(m[1]) / 1000;
|
|
94
|
+
}
|
|
83
95
|
return null;
|
|
84
96
|
}
|
|
85
97
|
|
|
86
98
|
// Mirror of backend workload_profile.py infer_profile_from_model — for pre-flight display.
|
|
87
99
|
// Sizing is only asserted when a param-count hint is found in the name; unknown
|
|
88
100
|
// sizing falls back to the 7B–8B/24GB+ default rather than guessing small or large.
|
|
89
|
-
|
|
101
|
+
// GPU lists match workload_profile.py's real preferred_gpus for each profile
|
|
102
|
+
// (not just a cosmetic display choice) -- "L4" previously appeared in the
|
|
103
|
+
// ≤3B bucket here but is not an actual GPU type this codebase ever routes to
|
|
104
|
+
// (see overflow_providers.py's GPU catalog), so it never matched anything
|
|
105
|
+
// real; the cheapest-available-first list below is the one the backend's
|
|
106
|
+
// own "inference_tiny" profile actually searches.
|
|
107
|
+
export function _inferServeProfile(modelName) {
|
|
90
108
|
const paramsB = _extractParamsB(modelName);
|
|
91
109
|
if (paramsB === null) return { label: 'inference (7B–8B model)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] };
|
|
92
|
-
if (paramsB <= 3) return { label: 'inference (≤3B model)', vram: '8+ GB', gpus: ['RTX
|
|
110
|
+
if (paramsB <= 3) return { label: 'inference (≤3B model)', vram: '8+ GB', gpus: ['RTX 3080', 'RTX 3090', 'RTX 4090'] };
|
|
93
111
|
if (paramsB <= 9) return { label: 'inference (7B–8B model)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] };
|
|
94
112
|
if (paramsB <= 35) return { label: 'inference (30B–34B model)', vram: '40+ GB', gpus: ['A6000', 'L40S', 'A100'] };
|
|
95
113
|
return { label: 'inference (70B+ model)', vram: '80+ GB', gpus: ['H100', 'A100'] };
|
|
@@ -176,7 +194,7 @@ async function waitForEndpoint(deploymentId, config, timeoutMs = VLLM_SERVE_WAIT
|
|
|
176
194
|
const spend = costPerHour * (elapsed / 3600);
|
|
177
195
|
|
|
178
196
|
blockLines = _writeBlock(blockLines, _renderLiveBlock(chalk, {
|
|
179
|
-
stageLine, elapsedSec: elapsed, statusWord, spend, id: deploymentId,
|
|
197
|
+
stageLine, elapsedSec: elapsed, statusWord, spend, id: deploymentId, maxCost,
|
|
180
198
|
}));
|
|
181
199
|
} catch {
|
|
182
200
|
// status check failed (transient network/API issue) — retry next tick
|
|
@@ -357,9 +375,10 @@ export async function serveCommand(config, args, chalk) {
|
|
|
357
375
|
const { model, flags } = parseServeArgs(args);
|
|
358
376
|
const customImage = flags.image || null;
|
|
359
377
|
const isLlamaCpp = flags.runtime === 'llama.cpp';
|
|
378
|
+
const isOllama = flags.runtime === 'ollama';
|
|
360
379
|
|
|
361
380
|
// Expand blessed alias (qwen-7b, llama-8b, qwen-coder-7b) to full model ID + GPU.
|
|
362
|
-
const vllmAlias = model && !customImage && !isLlamaCpp ? BLESSED_VLLM_MODELS[model] : null;
|
|
381
|
+
const vllmAlias = model && !customImage && !isLlamaCpp && !isOllama ? BLESSED_VLLM_MODELS[model] : null;
|
|
363
382
|
const effectiveModel = vllmAlias ? vllmAlias.model_id : model;
|
|
364
383
|
|
|
365
384
|
// Detect flags that ended up as positional args due to broken shell line continuation
|
|
@@ -427,16 +446,52 @@ export async function serveCommand(config, args, chalk) {
|
|
|
427
446
|
return;
|
|
428
447
|
}
|
|
429
448
|
|
|
430
|
-
// Endpoints bill continuously — require explicit cost control.
|
|
449
|
+
// Endpoints bill continuously — require explicit cost control. In an
|
|
450
|
+
// interactive terminal, ask for it right here instead of forcing a whole
|
|
451
|
+
// re-run of the command — but never invent consent: a non-TTY context
|
|
452
|
+
// (CI, piped input) or a cancelled/empty prompt still hard-fails exactly
|
|
453
|
+
// as before, and provisioning never starts without an explicit answer.
|
|
431
454
|
if (!flags.maxCost && !flags.persistent && !flags.dryRun) {
|
|
432
455
|
const example = model || (customImage ? '--image ...' : '<model>');
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
456
|
+
const failHard = () => {
|
|
457
|
+
console.error(chalk.red('\n ✗ Endpoints bill continuously until stopped. Specify a spending limit:\n'));
|
|
458
|
+
console.error(chalk.dim(` --max-cost 5 auto-stop when $5 is reached`));
|
|
459
|
+
console.error(chalk.dim(` --persistent run until you stop it manually\n`));
|
|
460
|
+
console.error(chalk.dim(` Example:`));
|
|
461
|
+
console.error(chalk.dim(` badgr serve ${example} --max-cost 10\n`));
|
|
462
|
+
process.exitCode = 1;
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
if (!process.stdin.isTTY) {
|
|
466
|
+
failHard();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
console.log(chalk.yellow('\n Endpoints bill continuously until stopped — no spending limit was given.'));
|
|
471
|
+
const { createInterface } = await import('readline');
|
|
472
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
473
|
+
const answer = await new Promise(resolve => {
|
|
474
|
+
rl.question(
|
|
475
|
+
chalk.dim(' Enter a max spend in USD to auto-stop, or "persistent" to run until you stop it manually [Ctrl+C to cancel]: $'),
|
|
476
|
+
resolve,
|
|
477
|
+
);
|
|
478
|
+
});
|
|
479
|
+
rl.close();
|
|
480
|
+
|
|
481
|
+
const trimmed = answer.trim();
|
|
482
|
+
if (/^p(ersistent)?$/i.test(trimmed)) {
|
|
483
|
+
flags.persistent = true;
|
|
484
|
+
console.log(chalk.dim(' Running persistent — stop billing with `badgr down`.\n'));
|
|
485
|
+
} else {
|
|
486
|
+
const parsed = parseFloat(trimmed);
|
|
487
|
+
if (!trimmed || !Number.isFinite(parsed) || parsed <= 0) {
|
|
488
|
+
console.log(chalk.dim('\n No valid answer given — not provisioning.\n'));
|
|
489
|
+
failHard();
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
flags.maxCost = parsed;
|
|
493
|
+
console.log(chalk.dim(` Max spend set to $${parsed.toFixed(2)}.\n`));
|
|
494
|
+
}
|
|
440
495
|
}
|
|
441
496
|
|
|
442
497
|
const envObj = parseEnvFlag(flags.env);
|
|
@@ -564,6 +619,7 @@ export async function serveCommand(config, args, chalk) {
|
|
|
564
619
|
return {
|
|
565
620
|
...(effectiveModel ? { model: effectiveModel } : {}),
|
|
566
621
|
...(isLlamaCpp ? { image: LLAMA_CPP_IMAGE } : customImage ? { image: customImage } : {}),
|
|
622
|
+
...(isOllama ? { runtime: 'ollama' } : {}),
|
|
567
623
|
...(flags.task ? { task: flags.task } : {}),
|
|
568
624
|
gpu: gpuOverride || gpu,
|
|
569
625
|
gpu_count: flags.count || 1,
|
|
@@ -578,6 +634,14 @@ export async function serveCommand(config, args, chalk) {
|
|
|
578
634
|
};
|
|
579
635
|
}
|
|
580
636
|
|
|
637
|
+
// `/serve` is a single synchronous call that blocks until routing,
|
|
638
|
+
// provisioning, and runtime startup have all either succeeded or failed
|
|
639
|
+
// server-side -- there is no live per-provider stage feed to poll during
|
|
640
|
+
// it (that would need an async two-phase API this endpoint doesn't have
|
|
641
|
+
// yet), so this is a plain pending indicator, not a progress bar.
|
|
642
|
+
console.log(chalk.dim(` Maximum spend: ${flags.maxCost ? `$${flags.maxCost.toFixed(2)}` : 'none (persistent)'}`));
|
|
643
|
+
console.log(chalk.dim(' Routing and provisioning...'));
|
|
644
|
+
|
|
581
645
|
let dep;
|
|
582
646
|
try {
|
|
583
647
|
dep = await callWithFallback(
|
|
@@ -649,7 +713,9 @@ export async function serveCommand(config, args, chalk) {
|
|
|
649
713
|
}
|
|
650
714
|
|
|
651
715
|
// ── Determine health check path ───────────────────────────────────────────
|
|
652
|
-
const resolvedHealthPath =
|
|
716
|
+
const resolvedHealthPath = isOllama
|
|
717
|
+
? (flags.healthPath || '/api/tags')
|
|
718
|
+
: _resolveHealthPath({ healthPath: flags.healthPath, isLlamaCpp, customImage, task: flags.task });
|
|
653
719
|
|
|
654
720
|
// Gated-model guidance is only shown when it's actually needed — on failure —
|
|
655
721
|
// not up front, so common launches stay short and uncluttered.
|
|
@@ -763,6 +829,11 @@ export async function serveCommand(config, args, chalk) {
|
|
|
763
829
|
if (customImage) console.log(` ${chalk.bold('Image:')} ${customImage}`);
|
|
764
830
|
if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
|
|
765
831
|
if (flags.idleTimeout) console.log(` ${chalk.bold('Idle timeout:')} ${flags.idleTimeout}m (auto-stops if idle — see Heartbeat below)`);
|
|
832
|
+
// A `serve` endpoint keeps running (and billing) after this command
|
|
833
|
+
// returns, unlike a finite `badgr run` job -- so this is the one place a
|
|
834
|
+
// management link matters: the customer needs a way back to it later
|
|
835
|
+
// that isn't re-running this exact command from shell history.
|
|
836
|
+
console.log(` ${chalk.bold('Manage:')} ${chalk.cyan(`${webBaseUrl(config)}/dashboard/jobs/${dep.deployment_id}`)}`);
|
|
766
837
|
console.log(` ${chalk.bold('Logs:')} badgr logs ${dep.deployment_id}`);
|
|
767
838
|
console.log(` ${chalk.bold('Restart:')} badgr restart ${dep.deployment_id}`);
|
|
768
839
|
console.log(` ${chalk.bold('Stop billing:')} ${chalk.cyan(`badgr down ${dep.deployment_id}`)}`);
|
|
@@ -770,7 +841,13 @@ export async function serveCommand(config, args, chalk) {
|
|
|
770
841
|
console.log();
|
|
771
842
|
console.log(chalk.dim(' Billing continues until you run: ') + chalk.cyan(`badgr down ${dep.deployment_id}`));
|
|
772
843
|
|
|
773
|
-
if (endpointReady &&
|
|
844
|
+
if (endpointReady && isOllama) {
|
|
845
|
+
console.log(` ${chalk.bold('Test with curl:')}`);
|
|
846
|
+
console.log(chalk.dim(` curl ${endpointUrl}/api/generate \\`));
|
|
847
|
+
console.log(chalk.dim(` -H "Content-Type: application/json" \\`));
|
|
848
|
+
console.log(chalk.dim(` -d '{"model":"${dep.model || effectiveModel}","prompt":"Hello","stream":false}'`));
|
|
849
|
+
console.log();
|
|
850
|
+
} else if (endpointReady && !customImage) {
|
|
774
851
|
// dep.endpoint_api_key is a per-endpoint key generated for this deployment
|
|
775
852
|
// (vLLM model serves only) — shown exactly once, here. Falls back to the
|
|
776
853
|
// account-wide key (truncated) for serves that don't get one yet
|
package/src/commands/shell.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { findCheapest, findById } from '../router.js';
|
|
2
|
-
import {
|
|
2
|
+
import { ensureBadgrReady } from '../onboarding.js';
|
|
3
3
|
|
|
4
4
|
export async function shellCommand(config, args, chalk) {
|
|
5
5
|
const gpuFlag = args.indexOf('--gpu');
|
|
6
6
|
const gpuId = gpuFlag !== -1 ? args[gpuFlag + 1] : null;
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
config = await ensureBadgrReady(config, chalk);
|
|
9
9
|
|
|
10
10
|
const gpu = gpuId ? findById(gpuId) : findCheapest({ tag: 'dev' });
|
|
11
11
|
|
package/src/commands/status.js
CHANGED
|
@@ -1,9 +1,32 @@
|
|
|
1
1
|
import { listDeployments as localDeployments } from '../store.js';
|
|
2
|
-
import { listDeployments as apiDeployments } from '../api.js';
|
|
2
|
+
import { listDeployments as apiDeployments, listAllActiveDeploymentsAdmin } from '../api.js';
|
|
3
3
|
|
|
4
4
|
export async function statusCommand(config, args, chalk) {
|
|
5
|
+
const isAdmin = args.includes('--admin');
|
|
5
6
|
let deployments = [];
|
|
6
7
|
|
|
8
|
+
if (isAdmin) {
|
|
9
|
+
// Admin-only (michaelhireitem@gmail.com): every user's active
|
|
10
|
+
// deployments, not just the caller's own -- see
|
|
11
|
+
// deployment_routes.py's GET /v1/admin/deployments/active. A non-admin
|
|
12
|
+
// account gets a plain 403 here, same as any other access-denied call.
|
|
13
|
+
if (!config.apiKey) {
|
|
14
|
+
console.log(chalk.red('\n Sign in required: badgr login\n'));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const data = await listAllActiveDeploymentsAdmin(config);
|
|
19
|
+
deployments = data?.deployments ?? [];
|
|
20
|
+
} catch (err) {
|
|
21
|
+
console.log(chalk.red(`\n Could not fetch admin status: ${err.message}\n`));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
// Already active/non-terminal on arrival (the backend route only
|
|
25
|
+
// returns those) -- no client-side terminal-status filter needed here
|
|
26
|
+
// the way the plain (own-account) listing below has to apply.
|
|
27
|
+
return _printDeploymentList(deployments, chalk, { admin: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
7
30
|
if (config.apiKey) {
|
|
8
31
|
try {
|
|
9
32
|
const data = await apiDeployments(config);
|
|
@@ -46,25 +69,43 @@ export async function statusCommand(config, args, chalk) {
|
|
|
46
69
|
const TERMINAL_STATUSES = new Set(['completed', 'succeeded', 'success', 'failed', 'stopped']);
|
|
47
70
|
const running = deployments.filter(d => !TERMINAL_STATUSES.has(d.status));
|
|
48
71
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
72
|
+
_printDeploymentList(running, chalk, { admin: false });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Shared renderer for both the plain (own-account) and `--admin` (every
|
|
76
|
+
// account) listings -- the two only ever differed in: whether an owner
|
|
77
|
+
// line is shown, whether the printed `badgr down` command needs `--admin`
|
|
78
|
+
// appended, the header/empty-state wording, and the badge falling back to
|
|
79
|
+
// the raw status word instead of a fixed "starting" (the admin route can
|
|
80
|
+
// return any non-terminal status, not just "running"/"starting"). Factored
|
|
81
|
+
// into one function so those four differences are the only things that can
|
|
82
|
+
// diverge, instead of the whole render (badge/GPU/rate/URL/model/billing
|
|
83
|
+
// total/stop-commands) being copy-pasted per mode.
|
|
84
|
+
function _printDeploymentList(deployments, chalk, { admin }) {
|
|
85
|
+
if (deployments.length === 0) {
|
|
86
|
+
console.log(admin ? chalk.dim('\n Nothing running for any account.\n') : chalk.dim('\n Nothing running.\n'));
|
|
87
|
+
if (!admin) {
|
|
88
|
+
console.log(chalk.dim(' badgr run python train.py'));
|
|
89
|
+
console.log(chalk.dim(' badgr serve meta-llama/Llama-3.1-8B-Instruct\n'));
|
|
90
|
+
}
|
|
53
91
|
return;
|
|
54
92
|
}
|
|
55
93
|
|
|
56
|
-
console.log(chalk.bold('\nRunning now:\n'));
|
|
57
|
-
for (const d of
|
|
94
|
+
console.log(chalk.bold(admin ? `\nRunning now (all accounts) — ${deployments.length} deployment(s):\n` : '\nRunning now:\n'));
|
|
95
|
+
for (const d of deployments) {
|
|
58
96
|
const id = d.deployment_id || d.name;
|
|
59
97
|
const type = d.workload_type === 'endpoint' ? 'endpoint' : 'job';
|
|
60
98
|
const gpu = d.gpu_type || '—';
|
|
61
99
|
const rate = d.cost_per_hour > 0 ? chalk.yellow(`$${d.cost_per_hour.toFixed(2)}/hr`) : '';
|
|
62
100
|
const badge = d.status === 'running'
|
|
63
101
|
? chalk.green('● running')
|
|
64
|
-
: chalk.yellow('● starting');
|
|
102
|
+
: chalk.yellow(admin ? `● ${d.status}` : '● starting');
|
|
65
103
|
|
|
66
104
|
console.log(` ${badge} ${chalk.bold(id)} ${type} ${gpu} ${rate}`);
|
|
67
|
-
|
|
105
|
+
if (admin) {
|
|
106
|
+
const owner = d.username || d.user_email || d.user_id || 'unknown user';
|
|
107
|
+
console.log(` ${chalk.dim('User:')} ${chalk.cyan(owner)}`);
|
|
108
|
+
}
|
|
68
109
|
if (d.workload_type === 'endpoint' && d.endpoint_url) {
|
|
69
110
|
console.log(` ${chalk.dim('URL:')} ${d.endpoint_url}`);
|
|
70
111
|
}
|
|
@@ -74,16 +115,19 @@ export async function statusCommand(config, args, chalk) {
|
|
|
74
115
|
console.log();
|
|
75
116
|
}
|
|
76
117
|
|
|
77
|
-
const billable =
|
|
118
|
+
const billable = deployments.filter(d => (d.cost_per_hour || 0) > 0);
|
|
78
119
|
if (billable.length > 0) {
|
|
79
120
|
const totalPerHr = billable.reduce((s, d) => s + (d.cost_per_hour || 0), 0);
|
|
80
|
-
|
|
121
|
+
const label = admin ? 'Total billing (all accounts):' : 'Total billing:';
|
|
122
|
+
console.log(` ${chalk.bold(label)} ${chalk.yellow(`$${totalPerHr.toFixed(2)}/hr`)}\n`);
|
|
81
123
|
}
|
|
82
124
|
|
|
83
125
|
console.log(chalk.bold('Stop billing:\n'));
|
|
84
|
-
for (const d of
|
|
126
|
+
for (const d of deployments) {
|
|
85
127
|
const id = d.deployment_id || d.name;
|
|
86
|
-
|
|
128
|
+
const cmd = admin ? `badgr down ${id} --admin` : `badgr down ${id}`;
|
|
129
|
+
const ownerSuffix = admin ? ` ${chalk.dim(`(${d.username || d.user_email || d.user_id})`)}` : '';
|
|
130
|
+
console.log(` ${chalk.cyan(cmd)}${ownerSuffix}`);
|
|
87
131
|
}
|
|
88
132
|
console.log();
|
|
89
133
|
}
|
package/src/commands/test-run.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ensureBadgrReady } from '../onboarding.js';
|
|
2
2
|
import { callApi, terminateDeployment } from '../api.js';
|
|
3
3
|
import { addReceipt, generateReceiptId } from '../store.js';
|
|
4
4
|
|
|
@@ -80,7 +80,7 @@ async function pollOutputOrDone(config, depId, expected, timeoutMs) {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
export async function testCommand(config, args, chalk) {
|
|
83
|
-
|
|
83
|
+
config = await ensureBadgrReady(config, chalk);
|
|
84
84
|
|
|
85
85
|
const flags = parseTestArgs(Array.isArray(args) ? args : []);
|
|
86
86
|
const providerKey = flags.provider ? flags.provider.toLowerCase() : 'tier1';
|
package/src/commands/train.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Enforces a max-runtime (default 120 min) and streams logs until completion.
|
|
7
7
|
*/
|
|
8
8
|
import { readFileSync, existsSync } from 'fs';
|
|
9
|
-
import {
|
|
9
|
+
import { ensureBadgrReady } from '../onboarding.js';
|
|
10
10
|
import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
|
|
11
11
|
import { normalizeTier, callWithFallback } from '../fallback.js';
|
|
12
12
|
import { monitorBatchJob, fmtRuntime } from '../batch.js';
|
|
@@ -168,7 +168,7 @@ export async function trainLoraCommand(config, args, chalk) {
|
|
|
168
168
|
return;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
|
|
171
|
+
config = await ensureBadgrReady(config, chalk);
|
|
172
172
|
|
|
173
173
|
// Build dataset input field
|
|
174
174
|
const input = { base_model: flags.baseModel, config_preset: flags.preset || 'small' };
|
|
@@ -304,7 +304,7 @@ export async function trainCommand(config, args, chalk) {
|
|
|
304
304
|
return;
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
-
|
|
307
|
+
config = await ensureBadgrReady(config, chalk);
|
|
308
308
|
|
|
309
309
|
if (!existsSync(configFile)) {
|
|
310
310
|
console.error(chalk.red(`\n ✗ Config file not found: ${configFile}\n`));
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Transcript appears in streaming logs and is written to stdout by the container.
|
|
7
7
|
*/
|
|
8
8
|
import { readFileSync, existsSync, statSync } from 'fs';
|
|
9
|
-
import {
|
|
9
|
+
import { ensureBadgrReady } from '../onboarding.js';
|
|
10
10
|
import { addReceipt, generateReceiptId } from '../store.js';
|
|
11
11
|
import { normalizeTier, callWithFallback } from '../fallback.js';
|
|
12
12
|
import { monitorBatchJob, fmtRuntime } from '../batch.js';
|
|
@@ -85,7 +85,7 @@ export async function transcribeCommand(config, args, chalk) {
|
|
|
85
85
|
return;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
config = await ensureBadgrReady(config, chalk);
|
|
89
89
|
|
|
90
90
|
const resolved = resolveAudioInput(input);
|
|
91
91
|
if (resolved.error) {
|
package/src/commands/up.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseSpec, validateSpec, specLines } from '../spec.js';
|
|
2
2
|
import { requireApiKey } from '../config.js';
|
|
3
|
+
import { ensureBadgrReady } from '../onboarding.js';
|
|
3
4
|
import { generateDeploymentId, generateReceiptId, addDeployment, addReceipt } from '../store.js';
|
|
4
5
|
import { createDeployment, callApi } from '../api.js';
|
|
5
6
|
|
|
@@ -64,7 +65,7 @@ export async function upCommand(config, args, chalk) {
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
// ── Provision via backend API ─────────────────────────────────────────────
|
|
67
|
-
|
|
68
|
+
config = await ensureBadgrReady(config, chalk);
|
|
68
69
|
|
|
69
70
|
console.log(chalk.bold('\n🚀 Provisioning\n'));
|
|
70
71
|
console.log(chalk.bold(' Spec'));
|
package/src/config.js
CHANGED
|
@@ -29,6 +29,25 @@ export function normalizeBaseUrl(url) {
|
|
|
29
29
|
return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
const DEFAULT_WEB_URL = 'https://aibadgr.com';
|
|
33
|
+
const LOCAL_WEB_URL = 'http://localhost:3000';
|
|
34
|
+
|
|
35
|
+
// The frontend origin to link out to (Run page, evidence/repro page, etc).
|
|
36
|
+
// start-local.sh points config.baseUrl at http://localhost:8000/v1 so the
|
|
37
|
+
// CLI's API calls hit the local backend; when it does, links the CLI prints
|
|
38
|
+
// should point at the local frontend (localhost:3000) too, not production.
|
|
39
|
+
export function webBaseUrl(config) {
|
|
40
|
+
const envWeb = process.env.BADGR_WEB_URL?.trim().replace(/\/+$/, '');
|
|
41
|
+
if (envWeb) return envWeb;
|
|
42
|
+
try {
|
|
43
|
+
const { hostname } = new URL(config?.baseUrl || DEFAULTS.baseUrl);
|
|
44
|
+
if (hostname === 'localhost' || hostname === '127.0.0.1') return LOCAL_WEB_URL;
|
|
45
|
+
} catch {
|
|
46
|
+
// fall through to the default below
|
|
47
|
+
}
|
|
48
|
+
return DEFAULT_WEB_URL;
|
|
49
|
+
}
|
|
50
|
+
|
|
32
51
|
function applyEnvOverrides(config) {
|
|
33
52
|
const envBase = process.env.BADGR_API_URL?.trim();
|
|
34
53
|
if (envBase) config.baseUrl = normalizeBaseUrl(envBase);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Shared customer-facing filtering for `/deployments/{id}/logs` lines.
|
|
2
|
+
//
|
|
3
|
+
// The backend (DeploymentService.get_logs()) already keeps provider names,
|
|
4
|
+
// pod IDs, and raw provider status out of what it returns for a finite job
|
|
5
|
+
// once it's ready or finished -- the one exception is while readiness is
|
|
6
|
+
// still being actively probed (health_path set, not yet ready), where the
|
|
7
|
+
// same provider_status=/uptime= evidence is deliberately included, since
|
|
8
|
+
// that's exactly the window where "pod alive but app not answering" vs.
|
|
9
|
+
// "stuck in a crash loop" needs telling apart. This module is the
|
|
10
|
+
// client-side half either way: structured `[dep-...] field=value`
|
|
11
|
+
// meta-lines carry state the CLI renders elsewhere (status bar, SSH line),
|
|
12
|
+
// so they're suppressed from the printed log stream. Kept in one place so
|
|
13
|
+
// `badgr run`/`badgr launch` (live streaming) and `badgr logs` (fetch/follow)
|
|
14
|
+
// can't drift on which fields they hide.
|
|
15
|
+
|
|
16
|
+
// Structured `[dep-...] field=value` lines rendered elsewhere, not printed as log text.
|
|
17
|
+
export const DEPLOYMENT_META_LOG_RE =
|
|
18
|
+
/^\[dep-[^\]]+\] (status|gpu|region|endpoint|cost|receipt|provider_status|uptime)=/;
|
|
19
|
+
|
|
20
|
+
// Inline provider-status fields that can appear embedded in a line rather than
|
|
21
|
+
// as a whole `[dep-...] field=value` meta-line.
|
|
22
|
+
const INLINE_PROVIDER_FIELD_RE = /\b(gpu_util|cpu_util|provider_status|uptime)=/;
|
|
23
|
+
|
|
24
|
+
export function isMetaLogLine(line) {
|
|
25
|
+
return DEPLOYMENT_META_LOG_RE.test(line) || INLINE_PROVIDER_FIELD_RE.test(line);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isErrorLogLine(line) {
|
|
29
|
+
return /^error\b/i.test(line) || /Error response from daemon/i.test(line);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Extract structured values from provider-status lines so the CLI can show
|
|
33
|
+
// them nicely (e.g. the SSH line in `badgr run`'s status bar).
|
|
34
|
+
export function parseProviderStatusLine(line) {
|
|
35
|
+
const gpuUtil = line.match(/\bgpu_util=([\d.]+)%/);
|
|
36
|
+
const cpuUtil = line.match(/\bcpu_util=([\d.]+)%/);
|
|
37
|
+
const ssh = line.match(/\bssh=(\S+)/);
|
|
38
|
+
const provSt = line.match(/\bprovider_status=(\S+)/);
|
|
39
|
+
return {
|
|
40
|
+
gpuUtil: gpuUtil ? parseFloat(gpuUtil[1]) : null,
|
|
41
|
+
cpuUtil: cpuUtil ? parseFloat(cpuUtil[1]) : null,
|
|
42
|
+
ssh: ssh ? ssh[1] : null,
|
|
43
|
+
providerStatus: provSt ? provSt[1] : null,
|
|
44
|
+
};
|
|
45
|
+
}
|
package/src/errors.js
CHANGED
|
@@ -17,7 +17,12 @@ export const CATALOG = {
|
|
|
17
17
|
// ── Capacity ──────────────────────────────────────────────────────────────
|
|
18
18
|
|
|
19
19
|
NO_CAPACITY: {
|
|
20
|
+
// Prefer the backend's specific detail (e.g. "CPU launch is not
|
|
21
|
+
// configured on this backend (missing HETZNER_API_TOKEN)") over the
|
|
22
|
+
// generic template — a real config/provider reason is far more
|
|
23
|
+
// actionable than "not available right now" when that's not why.
|
|
20
24
|
message: (ctx) =>
|
|
25
|
+
ctx.server_message ||
|
|
21
26
|
`No ${ctx.gpu || 'GPU'} available${ctx.region ? ` in ${ctx.region}` : ''} right now.`,
|
|
22
27
|
billing: 'never_started',
|
|
23
28
|
retried: false,
|
|
@@ -55,12 +60,16 @@ export const CATALOG = {
|
|
|
55
60
|
ctx.failure_category === 'compat_failure'
|
|
56
61
|
? 'GPU/CUDA driver incompatibility — the container requires a CUDA version this GPU does not support.'
|
|
57
62
|
: 'GPU was reserved but the container failed to start.',
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
// A real resource was reserved (the message says so) before this
|
|
64
|
+
// failed -- billing exposure was real, never "never_started". See
|
|
65
|
+
// fallback.js's own comment on why this code no longer triggers a
|
|
66
|
+
// client-side resubmission either.
|
|
67
|
+
billing: 'check_receipt',
|
|
68
|
+
retried: false,
|
|
60
69
|
hint: (ctx) =>
|
|
61
70
|
ctx.failure_category === 'compat_failure'
|
|
62
71
|
? ['Try a different base image or a different --gpu type.']
|
|
63
|
-
: ['
|
|
72
|
+
: ['Please try again, or try a different --gpu type.'],
|
|
64
73
|
severity: 'P2',
|
|
65
74
|
},
|
|
66
75
|
|
package/src/fallback.js
CHANGED
|
@@ -31,7 +31,7 @@ export class CapacityError extends Error {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
34
|
+
* Submit one deployment. Provider and tier fallback are backend-owned.
|
|
35
35
|
* Returns the deployment object on success.
|
|
36
36
|
* Throws CapacityError (pre-formatted for display) on unrecoverable failure.
|
|
37
37
|
* Re-throws payment errors (err.isPaymentRequired) for callers to handle.
|
|
@@ -42,18 +42,11 @@ export class CapacityError extends Error {
|
|
|
42
42
|
* @param {string} effectiveTier
|
|
43
43
|
* @param {object} chalk
|
|
44
44
|
* @param {object} labels - { thing: 'job'|'endpoint', cmd: 'badgr run'|'badgr serve' }
|
|
45
|
-
* @param {object} [opts]
|
|
46
|
-
* @param {boolean} [opts.allowTier2Fallback=true] - set false to disable tier-2 expansion
|
|
47
|
-
* @param {boolean} [opts.singleAttempt=false] - set true to skip both the same-tier
|
|
48
|
-
* provider retry and tier-2 expansion, failing immediately on the first error
|
|
49
|
-
* (badgr run --smoke's "one attempt" guarantee)
|
|
50
45
|
*/
|
|
51
|
-
export async function callWithFallback(endpoint, callOpts, buildBody, effectiveTier, chalk, labels
|
|
46
|
+
export async function callWithFallback(endpoint, callOpts, buildBody, effectiveTier, chalk, labels) {
|
|
52
47
|
const { callApi } = await import('./api.js');
|
|
53
48
|
const thing = labels?.thing ?? 'job';
|
|
54
49
|
const cmd = labels?.cmd ?? 'badgr run';
|
|
55
|
-
const singleAttempt = opts.singleAttempt === true;
|
|
56
|
-
const allowTier2Fallback = !singleAttempt && opts.allowTier2Fallback !== false; // default true
|
|
57
50
|
|
|
58
51
|
// 220s: comfortably above backend's BADGR_PROVISION_TIMEOUT_SECONDS (default
|
|
59
52
|
// 200s, itself set above deployment_service.py's 180s routing-search
|
|
@@ -93,7 +86,10 @@ export async function callWithFallback(endpoint, callOpts, buildBody, effectiveT
|
|
|
93
86
|
if (key) {
|
|
94
87
|
const ctx = {
|
|
95
88
|
gpu: d.filters?.gpu ?? d.gpu,
|
|
96
|
-
|
|
89
|
+
// Server uses the literal string "any" as a filters.region placeholder
|
|
90
|
+
// when no region was requested — don't surface that as if the user
|
|
91
|
+
// had actually asked for a region named "any" (e.g. "in any right now").
|
|
92
|
+
region: d.filters?.region && d.filters.region !== 'any' ? d.filters.region : undefined,
|
|
97
93
|
failure_category: d.failure_category,
|
|
98
94
|
low_cost_failed: d.low_cost_provider_failed,
|
|
99
95
|
server_message: d.message,
|
|
@@ -119,31 +115,58 @@ export async function callWithFallback(endpoint, callOpts, buildBody, effectiveT
|
|
|
119
115
|
firstErr = err;
|
|
120
116
|
}
|
|
121
117
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
118
|
+
// A stale/expired/revoked API key is not capacity trouble — the request
|
|
119
|
+
// never got past auth, so retrying against another provider or expanding
|
|
120
|
+
// to tier-2 can't help and just prints misleading "expanding search"
|
|
121
|
+
// language over a problem those retries cannot fix. Short-circuit before
|
|
122
|
+
// any fallback attempt — but in an interactive terminal, "short-circuit"
|
|
123
|
+
// means offering the same browser login-and-resume flow every other
|
|
124
|
+
// auth failure gets, not just printing a hint and exiting: the person is
|
|
125
|
+
// sitting right there. Retry exactly once with the fresh key; a repeat
|
|
126
|
+
// 401/403 (or non-interactive/CI) falls through to the plain AUTH_FAILED
|
|
127
|
+
// message, same as before this existed.
|
|
128
|
+
if (firstErr.httpStatus === 401 || firstErr.httpStatus === 403) {
|
|
129
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
130
|
+
try {
|
|
131
|
+
const { ensureLoggedIn } = await import('./onboarding.js');
|
|
132
|
+
console.log(chalk.yellow('\n Your saved API key was rejected. Signing in again...'));
|
|
133
|
+
const fresh = await ensureLoggedIn({ apiKey: callOpts.apiKey, baseUrl: callOpts.baseUrl }, chalk);
|
|
134
|
+
callOpts.apiKey = fresh.apiKey;
|
|
135
|
+
return await attempt(buildBody());
|
|
136
|
+
} catch (retryErr) {
|
|
137
|
+
if (retryErr.isPaymentRequired) throw retryErr;
|
|
138
|
+
// Falls through to the same AUTH_FAILED message below, whether the
|
|
139
|
+
// re-login itself failed or the retried request 401/403'd again.
|
|
140
|
+
}
|
|
135
141
|
}
|
|
142
|
+
throw new CapacityError(formatCliError('AUTH_FAILED', {}, chalk));
|
|
136
143
|
}
|
|
137
144
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
145
|
+
const d = firstErr.errorData;
|
|
146
|
+
|
|
147
|
+
// No client-side "provider retry" here on purpose. The backend
|
|
148
|
+
// (reliability_engine.py / deployment_service.py's legacy provisioning
|
|
149
|
+
// loop) already tries every viable provider/offer for a request within
|
|
150
|
+
// the ONE deployment it creates before ever returning a failure -- a
|
|
151
|
+
// PROVISIONING_FAILED/PROVIDER_ADAPTER_ERROR response means that whole
|
|
152
|
+
// internal search already ran and a real, billable resource was very
|
|
153
|
+
// likely created and destroyed along the way (see deployment_service.
|
|
154
|
+
// _reliability_result_failure_reason and the legacy loop's own
|
|
155
|
+
// smoke-check-failure path, both of which only report these codes once
|
|
156
|
+
// a resource actually existed). A second /run or /serve call here would
|
|
157
|
+
// be an entirely new deployment repeating that same internal search from
|
|
158
|
+
// scratch -- duplicate billable exposure for a workload that was never a
|
|
159
|
+
// capacity problem in the first place. This CLI previously did retry
|
|
160
|
+
// once here with a `prefer_different_provider` flag the backend never
|
|
161
|
+
// actually reads (dead parameter, verified against the API source) --
|
|
162
|
+
// that retry never changed routing behavior, it just doubled exposure.
|
|
163
|
+
// A truthful WORKLOAD_START_FAILED/COMMAND_NOT_FOUND response (see
|
|
164
|
+
// serverToKey below) isn't even reachable here -- CapacityError is only
|
|
165
|
+
// for the capacity-flavored codes this function's own contract covers.
|
|
166
|
+
|
|
167
|
+
// The backend owns all provider and tier expansion for the deployment.
|
|
168
|
+
// Even NO_CAPACITY is terminal for this one submission: a second POST
|
|
169
|
+
// would create a second deployment id and split cleanup/audit ownership.
|
|
147
170
|
|
|
148
171
|
throw buildCapacityError(firstErr, false);
|
|
149
172
|
}
|