badgr-cli 1.1.1 → 1.1.3

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.
Files changed (73) hide show
  1. package/LICENSE +207 -0
  2. package/README.md +13 -6
  3. package/package.json +44 -2
  4. package/src/api.js +16 -0
  5. package/src/badgr.js +26 -10
  6. package/src/commands/batch.js +11 -0
  7. package/src/commands/billing.js +3 -3
  8. package/src/commands/comfyui.js +31 -15
  9. package/src/commands/connect.js +4 -1
  10. package/src/commands/diagnose.js +493 -0
  11. package/src/commands/embed.js +13 -10
  12. package/src/commands/job.js +246 -0
  13. package/src/commands/launch.js +152 -16
  14. package/src/commands/login.js +75 -20
  15. package/src/commands/run.js +74 -16
  16. package/src/commands/sbatch.js +6 -1
  17. package/src/commands/serve.js +44 -30
  18. package/src/commands/train.js +8 -12
  19. package/src/commands/transcribe.js +13 -10
  20. package/src/credentials.js +33 -0
  21. package/src/envFlag.js +10 -0
  22. package/src/fallback.js +13 -2
  23. package/src/onboarding.js +8 -1
  24. package/src/progress.js +48 -0
  25. package/src/commands/task.js +0 -25
  26. package/tests/agent-images.test.js +0 -17
  27. package/tests/api.test.js +0 -168
  28. package/tests/artifactDownload.test.js +0 -113
  29. package/tests/artifacts.test.js +0 -168
  30. package/tests/batch.test.js +0 -641
  31. package/tests/browser.test.js +0 -51
  32. package/tests/capacity.test.js +0 -68
  33. package/tests/commands.test.js +0 -417
  34. package/tests/config.test.js +0 -96
  35. package/tests/connect.test.js +0 -83
  36. package/tests/detect.test.js +0 -191
  37. package/tests/down.test.js +0 -150
  38. package/tests/errors.test.js +0 -130
  39. package/tests/fallback-timeout.test.js +0 -41
  40. package/tests/fanout.test.js +0 -124
  41. package/tests/gpu-doctor-classifiers.test.js +0 -402
  42. package/tests/gpu-doctor-doctor.test.js +0 -304
  43. package/tests/gpu-doctor-probe-cache.test.js +0 -110
  44. package/tests/gpu-doctor-probes.test.js +0 -257
  45. package/tests/heartbeat.test.js +0 -70
  46. package/tests/job-progress-poll.test.js +0 -136
  47. package/tests/launch-command-argv.test.js +0 -93
  48. package/tests/launch-readiness.test.js +0 -403
  49. package/tests/launch.test.js +0 -440
  50. package/tests/onboarding.test.js +0 -134
  51. package/tests/productized-dry-run.test.js +0 -141
  52. package/tests/productized-runners.test.js +0 -237
  53. package/tests/pull.test.js +0 -266
  54. package/tests/rerun.test.js +0 -94
  55. package/tests/restart.test.js +0 -88
  56. package/tests/router.test.js +0 -98
  57. package/tests/run-lifecycle.test.js +0 -1054
  58. package/tests/sbatch.test.js +0 -190
  59. package/tests/secrets.test.js +0 -16
  60. package/tests/serve-apps.test.js +0 -189
  61. package/tests/serve-lifecycle.test.js +0 -931
  62. package/tests/slurm.test.js +0 -77
  63. package/tests/spec.test.js +0 -201
  64. package/tests/status.test.js +0 -73
  65. package/tests/store.test.js +0 -187
  66. package/tests/task.test.js +0 -109
  67. package/tests/template.test.js +0 -556
  68. package/tests/train-lora-dataset.test.js +0 -176
  69. package/tests/upload.test.js +0 -79
  70. package/tests/workload-rerun.test.js +0 -56
  71. package/tests/workload-spec.test.js +0 -180
  72. package/tests/workload-templates.test.js +0 -865
  73. package/tests/workload-workspace-paths.test.js +0 -46
@@ -5,13 +5,14 @@ import { createWriteStream } from 'fs';
5
5
  import { requireApiKey } from '../config.js';
6
6
  import { callApi, terminateDeployment, uploadBlob, quoteRun } from '../api.js';
7
7
  import { addReceipt, updateReceipt, generateReceiptId, selectedComputeFromDeployment } from '../store.js';
8
- import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
8
+ import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD, SMOKE_MAX_COST_USD, SMOKE_MAX_RUNTIME_MINUTES } from '../fallback.js';
9
9
  import { formatCliError } from '../errors.js';
10
10
  import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides } from '../catalog.js';
11
- import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass } from '../progress.js';
11
+ import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass, printCapacityPreview, formatTierLabel } from '../progress.js';
12
12
  import { detectWorkload, workloadTypeLabel } from '../detect.js';
13
13
  import { ensureBadgrReady } from '../onboarding.js';
14
14
  import { VM_CLASSES, parseGbSize } from '../spec.js';
15
+ import { parseEnvFlag } from '../envFlag.js';
15
16
 
16
17
  function vmClassLine(sizeKey) {
17
18
  const vmClass = VM_CLASSES[sizeKey];
@@ -56,6 +57,7 @@ export function parseRunArgs(args) {
56
57
  if (flagArgs[i] === '--count') { flags.count = parseInt(flagArgs[++i], 10); i++; continue; }
57
58
  if (flagArgs[i] === '--region') { flags.region = flagArgs[++i]; i++; continue; }
58
59
  if (flagArgs[i] === '--tier') { flags.tier = flagArgs[++i]; i++; continue; }
60
+ if (flagArgs[i] === '--smoke') { flags.smoke = true; i++; continue; }
59
61
  if (flagArgs[i] === '--max-price') { flags.maxPrice = parseFloat(flagArgs[++i]); i++; continue; }
60
62
  if (flagArgs[i] === '--name') { flags.name = flagArgs[++i]; i++; continue; }
61
63
  if (flagArgs[i] === '--detach') { flags.detach = true; i++; continue; }
@@ -98,15 +100,6 @@ export function parseRunArgs(args) {
98
100
  return { flags, positional, commandArgv };
99
101
  }
100
102
 
101
- function parseEnvFlag(envList) {
102
- const obj = {};
103
- for (const kv of (envList || [])) {
104
- const idx = kv.indexOf('=');
105
- if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
106
- }
107
- return obj;
108
- }
109
-
110
103
  // Heuristic for --env keys that look like secrets — used to warn (not
111
104
  // block) since there is no dashboard --profile injection path yet and
112
105
  // --env is currently the only way to get a provider key into a launch VM.
@@ -417,7 +410,7 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
417
410
 
418
411
  // Known badgr run flags — used to detect broken shell line continuation.
419
412
  const _KNOWN_RUN_FLAGS = new Set([
420
- '--gpu', '--image', '--count', '--region', '--tier', '--max-price', '--name',
413
+ '--gpu', '--image', '--count', '--region', '--tier', '--smoke', '--max-price', '--name',
421
414
  '--detach', '--no-detach', '--fallback', '--no-fallback', '--strict-capacity',
422
415
  '--no-expanded-search', '--max-runtime', '--max-cost', '--min-vram', '--gpu-memory',
423
416
  '--cpu', '--memory', '--no-gpu', '--env',
@@ -458,7 +451,10 @@ async function _zipDirectory(dirPath, chalk) {
458
451
  throw new Error(`Directory not found: ${absDir}`);
459
452
  }
460
453
 
461
- const tmpFile = path.join(os.tmpdir(), `badgr-upload-${Date.now()}.zip`);
454
+ // Random suffix (not just Date.now()) avoids two concurrent zips
455
+ // (e.g. a real upload racing a --dry-run size estimate) colliding on the
456
+ // same filename within the same millisecond.
457
+ const tmpFile = path.join(os.tmpdir(), `badgr-upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.zip`);
462
458
 
463
459
  const { default: archiver } = await import('archiver');
464
460
  await new Promise((resolve, reject) => {
@@ -478,6 +474,18 @@ async function _zipDirectory(dirPath, chalk) {
478
474
  return tmpFile;
479
475
  }
480
476
 
477
+ // Local-only size estimate for a dry run: zips the project exactly like a
478
+ // real upload would, measures it, then deletes the temp file — no network
479
+ // call, no uploadBlob, so `--dry-run` never actually uploads the project.
480
+ async function _estimateUploadSizeMb(dirPath) {
481
+ const tmpFile = await _zipDirectory(dirPath);
482
+ try {
483
+ return (fs.statSync(tmpFile).size / 1024 / 1024).toFixed(1);
484
+ } finally {
485
+ fs.unlinkSync(tmpFile);
486
+ }
487
+ }
488
+
481
489
  export async function _uploadCodeZip(config, dirPath, chalk) {
482
490
  process.stdout.write(chalk.dim(' Packing project...'));
483
491
  const tmpFile = await _zipDirectory(dirPath, chalk);
@@ -685,6 +693,28 @@ export async function runCommand(config, args, chalk, opts = {}) {
685
693
  return;
686
694
  }
687
695
 
696
+ // ── Smoke mode: cheapest compatible provider for local/dev test runs ──────
697
+ // --smoke, or BADGR_DEV_CHEAPEST=1 in the environment for a local default so
698
+ // it doesn't have to be typed every time. An explicit --tier always wins —
699
+ // the user asked for a specific tier, so smoke's routing/caps don't apply.
700
+ const devCheapestEnv = process.env.BADGR_DEV_CHEAPEST === '1' || process.env.BADGR_DEV_CHEAPEST === 'true';
701
+ const smokeMode = !flags.tier && (flags.smoke || devCheapestEnv);
702
+
703
+ if (smokeMode) {
704
+ if (flags.detach) {
705
+ console.error(chalk.red(' ✗ --smoke requires teardown to run in the foreground — remove --detach.'));
706
+ process.exitCode = 1;
707
+ return;
708
+ }
709
+ if (flags.workspace) {
710
+ console.error(chalk.red(' ✗ --smoke does not support --workspace (no persistent storage for smoke runs).'));
711
+ process.exitCode = 1;
712
+ return;
713
+ }
714
+ if (flags.maxCost === undefined) flags.maxCost = SMOKE_MAX_COST_USD;
715
+ if (flags.maxRuntime === undefined) flags.maxRuntime = SMOKE_MAX_RUNTIME_MINUTES;
716
+ }
717
+
688
718
  if (!flags.maxCost && !flags.dryRun && isLocalPath && process.stdin.isTTY && process.stdout.isTTY) {
689
719
  try {
690
720
  const { input } = await import('@inquirer/prompts');
@@ -775,6 +805,12 @@ export async function runCommand(config, args, chalk, opts = {}) {
775
805
  console.log(` ${chalk.bold(isLaunch || flags.noGpu ? 'Compute:' : 'GPU:')} ${isLaunch || flags.noGpu ? 'CPU VM (no GPU)' : (gpu || chalk.dim('auto'))}`);
776
806
  if (isLaunch && flags.size) console.log(` ${chalk.bold('VM class:')} ${vmClassLine(flags.size)}`);
777
807
  if (isLaunch && quotedRate != null) console.log(` ${chalk.bold('Badgr rate:')} $${quotedRate.toFixed(2)}/hour`);
808
+ if (isLaunch && flags.authRequired?.provider) {
809
+ const authLine = flags.authRequired.status === 'connected'
810
+ ? chalk.green(`${flags.authRequired.provider} connected`)
811
+ : chalk.yellow(`${flags.authRequired.provider} not connected — will prompt (or run: badgr connect ${flags.authRequired.provider})`);
812
+ console.log(` ${chalk.bold('Auth:')} ${authLine}`);
813
+ }
778
814
  if (flags.minVram) console.log(` ${chalk.bold('Min VRAM:')} ${flags.minVram} GB`);
779
815
  if (flags.cpu) console.log(` ${chalk.bold('CPU:')} ${flags.cpu} cores`);
780
816
  if (flags.memory) console.log(` ${chalk.bold('Memory:')} ${flags.memory} GB`);
@@ -787,7 +823,28 @@ export async function runCommand(config, args, chalk, opts = {}) {
787
823
  if (flags.retrySafe) console.log(` ${chalk.bold('Retry-safe:')} enabled`);
788
824
  if (flags.resumeCmd) console.log(` ${chalk.bold('Resume cmd:')} ${flags.resumeCmd}`);
789
825
  if (flags.artifacts?.length) console.log(` ${chalk.bold('Artifacts:')} ${flags.artifacts.join(', ')}`);
790
- console.log(chalk.dim('\n Remove --dry-run to provision.\n'));
826
+ if (!isLaunch) console.log(` ${chalk.bold('Tier:')} ${formatTierLabel(effectiveTier)}`);
827
+ if (smokeMode) console.log(` ${chalk.bold('Routing:')} cheapest compatible (smoke mode)`);
828
+
829
+ // Upload-size estimate is GPU-job-specific (spec: "badgr run ... upload
830
+ // size") and does a real local zip pass — skip it for CPU launches
831
+ // (badgr launch always sources from '.'), where it would add a real,
832
+ // possibly-slow filesystem operation to every dry-run preview for no
833
+ // requested benefit.
834
+ if (isLocalPath && !isLaunch) {
835
+ try {
836
+ const sizeMb = await _estimateUploadSizeMb(path.resolve(firstArg));
837
+ console.log(` ${chalk.bold('Upload size:')} ~${sizeMb} MB (not uploaded)`);
838
+ } catch (err) {
839
+ console.log(chalk.dim(` Upload size: could not estimate (${err.message})`));
840
+ }
841
+ }
842
+
843
+ console.log();
844
+ if (!isLaunch && !flags.noGpu) {
845
+ await printCapacityPreview(chalk, config, { gpu, region: flags.region?.toUpperCase(), maxPrice: flags.maxPrice });
846
+ }
847
+ console.log(chalk.dim(' Remove --dry-run to provision.\n'));
791
848
  return;
792
849
  }
793
850
 
@@ -815,6 +872,7 @@ export async function runCommand(config, args, chalk, opts = {}) {
815
872
  console.log(` ${chalk.bold('Max cost:')} ${maxCostLabel}`);
816
873
  console.log(` ${chalk.bold('Max runtime:')} ${runtimeLabel}`);
817
874
  console.log(` ${chalk.bold('Auto-stop:')} ${maxCost ? 'enabled' : chalk.yellow('disabled — stop manually with badgr down')}`);
875
+ if (smokeMode) console.log(` ${chalk.bold('Routing:')} cheapest compatible (smoke mode)`);
818
876
  if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice.toFixed(2)}/hr`);
819
877
  if (detach) console.log(` ${chalk.dim('(detached — returns immediately)')}`);
820
878
  if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${redactEnvForDisplay(flags.env)}`);
@@ -824,7 +882,6 @@ export async function runCommand(config, args, chalk, opts = {}) {
824
882
  if (flags.artifacts?.length) console.log(` ${chalk.bold('Artifacts:')} ${flags.artifacts.join(', ')}`);
825
883
  console.log();
826
884
 
827
-
828
885
  // Resolve --workspace name → ws_… ID before submitting
829
886
  let resolvedWorkspaceId = flags.workspace ?? null;
830
887
  if (resolvedWorkspaceId && !resolvedWorkspaceId.startsWith('ws_')) {
@@ -880,6 +937,7 @@ export async function runCommand(config, args, chalk, opts = {}) {
880
937
  max_price_per_hour: flags.maxPrice,
881
938
  name: flags.name,
882
939
  tier: tierOverride || effectiveTier,
940
+ ...(smokeMode ? { routing: 'cheapest' } : {}),
883
941
  ...(Object.keys(envObj).length > 0 ? { env: envObj } : {}),
884
942
  max_runtime_seconds: effectiveMaxRuntime * 60,
885
943
  ...(maxCost ? { max_cost_usd: maxCost } : {}),
@@ -904,7 +962,7 @@ export async function runCommand(config, args, chalk, opts = {}) {
904
962
  effectiveTier,
905
963
  chalk,
906
964
  { thing: 'job', cmd: cmdName },
907
- { allowTier2Fallback: !flags.noFallback },
965
+ { allowTier2Fallback: !flags.noFallback, singleAttempt: smokeMode },
908
966
  );
909
967
  } catch (err) {
910
968
  if (err.isPaymentRequired) {
@@ -22,6 +22,7 @@ import { normalizeGpuType } from '../spec.js';
22
22
  import { monitorBatchJob, fmtRuntime } from '../batch.js';
23
23
  import { runFanOut, DEFAULT_CONCURRENCY } from '../fanout.js';
24
24
  import { callApi } from '../api.js';
25
+ import { printCapacityPreview } from '../progress.js';
25
26
 
26
27
  const DEFAULT_IMAGE = 'python:3.11-slim';
27
28
  const DEFAULT_MAX_RUNTIME_MIN = 60;
@@ -153,9 +154,13 @@ export async function sbatchCommand(config, args, chalk) {
153
154
  const taskIds = isArray ? job.arrayIndices : [null];
154
155
  const concurrency = flags.maxConcurrency ?? DEFAULT_CONCURRENCY;
155
156
 
157
+ const resolvedGpu = job.gpuCount > 0 ? (job.gpuType ? normalizeGpuType(job.gpuType) : 'AUTO') : 'NONE';
158
+
156
159
  if (flags.dryRun) {
160
+ requireApiKey(config);
157
161
  console.log(chalk.dim(` Dry run — no GPU provisioned. Would submit ${taskIds.length} job(s) (max ${Math.min(concurrency, taskIds.length)} concurrent) with:`));
158
- console.log(chalk.dim(` image=${opts.image} tier=${opts.tier} max_cost=$${opts.maxCostUsd} max_runtime=${opts.maxRuntimeMinutes}min\n`));
162
+ console.log(chalk.dim(` image=${opts.image} gpu=${resolvedGpu} tier=${opts.tier} max_cost=$${opts.maxCostUsd} max_runtime=${opts.maxRuntimeMinutes}min\n`));
163
+ if (resolvedGpu !== 'NONE') await printCapacityPreview(chalk, config, { gpu: resolvedGpu, region: opts.region?.toUpperCase(), maxPrice: undefined });
159
164
  return;
160
165
  }
161
166
 
@@ -3,8 +3,10 @@ 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';
7
+ import { parseEnvFlag } from '../envFlag.js';
6
8
  import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides, BLESSED_VLLM_MODELS, isLikelyGatedModel } from '../catalog.js';
7
- import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass } from '../progress.js';
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';
8
10
 
9
11
  const LLAMA_CPP_IMAGE = 'michaelmanleyx/llama-cpp:server-cuda';
10
12
 
@@ -45,6 +47,7 @@ export function parseServeArgs(args) {
45
47
  if (args[i] === '--hf-repo') { flags.hfRepo = args[++i]; i++; continue; }
46
48
  if (args[i] === '--hf-file') { flags.hfFile = args[++i]; i++; continue; }
47
49
  if (args[i] === '--list-aliases') { flags.listAliases = true; i++; continue; }
50
+ if (args[i] === '--dry-run') { flags.dryRun = true; i++; continue; }
48
51
  if (args[i] === '--env') {
49
52
  const kv = args[++i]; i++;
50
53
  if (!flags.env) flags.env = [];
@@ -57,15 +60,6 @@ export function parseServeArgs(args) {
57
60
  return { model, flags };
58
61
  }
59
62
 
60
- function parseEnvFlag(envList) {
61
- const obj = {};
62
- for (const kv of (envList || [])) {
63
- const idx = kv.indexOf('=');
64
- if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
65
- }
66
- return obj;
67
- }
68
-
69
63
  function envObjHasHfToken(envList) {
70
64
  return (envList || []).some(kv => kv.startsWith('HF_TOKEN='));
71
65
  }
@@ -122,6 +116,17 @@ function _detectHealthPath(image) {
122
116
  return null;
123
117
  }
124
118
 
119
+ // Priority: explicit --health-path > llama.cpp → /health > task-specific >
120
+ // vLLM → /models > auto-detect custom image > null. Pure (no `dep` needed),
121
+ // so both the dry-run preview and the real post-launch health check use the
122
+ // exact same resolution and can never show a different path than they poll.
123
+ function _resolveHealthPath({ healthPath, isLlamaCpp, customImage, task }) {
124
+ if (healthPath) return healthPath;
125
+ if (isLlamaCpp) return '/health';
126
+ if (!customImage) return (task === 'transcribe' || task === 'image') ? '/health' : '/models';
127
+ return _detectHealthPath(customImage); // '/system_stats' for comfyui, null otherwise
128
+ }
129
+
125
130
  /**
126
131
  * Wait for the deployment's app-level endpoint to become ready by polling Badgr's
127
132
  * own deployment status (GET /deployments/{id}) — never the RunPod proxy/pod
@@ -211,7 +216,7 @@ const _KNOWN_SERVE_FLAGS = new Set([
211
216
  '--gpu', '--image', '--task', '--count', '--region', '--tier', '--max-price',
212
217
  '--name', '--no-wait', '--max-cost', '--idle-timeout', '--health-path', '--check-nodes',
213
218
  '--no-fallback', '--strict-capacity', '--no-expanded-search', '--env',
214
- '--persistent', '--yes', '-y', '--runtime', '--hf-repo', '--hf-file',
219
+ '--persistent', '--yes', '-y', '--runtime', '--hf-repo', '--hf-file', '--dry-run',
215
220
  ]);
216
221
 
217
222
  function extractFlag(args, flagName) {
@@ -396,7 +401,14 @@ export async function serveCommand(config, args, chalk) {
396
401
  return;
397
402
  }
398
403
 
399
- config = await ensureBadgrReady(config, chalk);
404
+ // A dry run previews the plan only and never provisions or spends anything,
405
+ // so it doesn't need the interactive browser login — just a stored key, to
406
+ // keep its existing fail-fast behavior in non-interactive contexts (tests/CI).
407
+ if (flags.dryRun) {
408
+ requireApiKey(config);
409
+ } else {
410
+ config = await ensureBadgrReady(config, chalk);
411
+ }
400
412
 
401
413
  // ── Validate flags early ───────────────────────────────────────────────────
402
414
  if (flags.count !== undefined && (!Number.isFinite(flags.count) || flags.count < 1)) {
@@ -416,7 +428,7 @@ export async function serveCommand(config, args, chalk) {
416
428
  }
417
429
 
418
430
  // Endpoints bill continuously — require explicit cost control.
419
- if (!flags.maxCost && !flags.persistent) {
431
+ if (!flags.maxCost && !flags.persistent && !flags.dryRun) {
420
432
  const example = model || (customImage ? '--image ...' : '<model>');
421
433
  console.error(chalk.red('\n ✗ Endpoints bill continuously until stopped. Specify a spending limit:\n'));
422
434
  console.error(chalk.dim(` --max-cost 5 auto-stop when $5 is reached`));
@@ -469,8 +481,15 @@ export async function serveCommand(config, args, chalk) {
469
481
  if (flags.gpu) headerLines.push(['GPU', gpuLabel]);
470
482
  if (flags.task) headerLines.push(['Task', flags.task]);
471
483
  if (flags.env?.length) headerLines.push(['Env', flags.env.join(', ')]);
484
+ if (flags.dryRun) {
485
+ headerLines.push(['Tier', formatTierLabel(effectiveTier)]);
486
+ const previewHealthPath = _resolveHealthPath({ healthPath: flags.healthPath, isLlamaCpp, customImage, task: flags.task });
487
+ headerLines.push(['Health path', previewHealthPath || chalk.dim('none (custom image, unset)')]);
488
+ }
472
489
 
473
- console.log(chalk.bold(`\n⚡ Serving ${title}\n`));
490
+ console.log(chalk.bold(flags.dryRun
491
+ ? `\n⚡ Dry run — no GPU will be provisioned — ${title}\n`
492
+ : `\n⚡ Serving ${title}\n`));
474
493
  const labelWidth = Math.max(...headerLines.map(([label]) => label.length)) + 1;
475
494
  for (const [label, value] of headerLines) {
476
495
  console.log(` ${chalk.bold(`${label}:`.padEnd(labelWidth + 1))}${value}`);
@@ -488,6 +507,16 @@ export async function serveCommand(config, args, chalk) {
488
507
  }
489
508
  console.log();
490
509
 
510
+ if (flags.dryRun) {
511
+ await printCapacityPreview(chalk, config, {
512
+ gpu,
513
+ region: flags.region ? flags.region.toUpperCase() : undefined,
514
+ maxPrice: flags.maxPrice,
515
+ });
516
+ console.log(chalk.dim(' Remove --dry-run to provision.\n'));
517
+ return;
518
+ }
519
+
491
520
  const STAGE_TOTAL = 5;
492
521
  let stageN = 1;
493
522
 
@@ -620,22 +649,7 @@ export async function serveCommand(config, args, chalk) {
620
649
  }
621
650
 
622
651
  // ── Determine health check path ───────────────────────────────────────────
623
- // Priority: explicit --health-path > llama.cpp /health > task-specific > vLLM → /models > auto-detect custom image > null
624
- let resolvedHealthPath;
625
- if (flags.healthPath) {
626
- resolvedHealthPath = flags.healthPath;
627
- } else if (isLlamaCpp) {
628
- resolvedHealthPath = '/health';
629
- } else if (!customImage) {
630
- // Managed runtimes for transcribe/image expose /health; vLLM (chat, embed) uses /models
631
- if (flags.task === 'transcribe' || flags.task === 'image') {
632
- resolvedHealthPath = '/health';
633
- } else {
634
- resolvedHealthPath = '/models';
635
- }
636
- } else {
637
- resolvedHealthPath = _detectHealthPath(customImage); // '/system_stats' for comfyui, null otherwise
638
- }
652
+ const resolvedHealthPath = _resolveHealthPath({ healthPath: flags.healthPath, isLlamaCpp, customImage, task: flags.task });
639
653
 
640
654
  // Gated-model guidance is only shown when it's actually needed — on failure —
641
655
  // not up front, so common launches stay short and uncluttered.
@@ -10,8 +10,9 @@ import { requireApiKey } from '../config.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';
13
- import { pollJobUntilTerminal, renderJobClosingBlock } from '../progress.js';
13
+ import { pollJobUntilTerminal, renderJobClosingBlock, printCapacityPreview, formatTierLabel } from '../progress.js';
14
14
  import { uploadBlob } from '../api.js';
15
+ import { parseEnvFlag } from '../envFlag.js';
15
16
 
16
17
  const MAX_CONFIG_B = 512 * 1024; // 512 KB config limit
17
18
 
@@ -64,15 +65,6 @@ export function parseTrainArgs(args) {
64
65
  return { configFile: positional[0] || null, flags };
65
66
  }
66
67
 
67
- function parseEnvFlag(envList) {
68
- const obj = {};
69
- for (const kv of (envList || [])) {
70
- const idx = kv.indexOf('=');
71
- if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
72
- }
73
- return obj;
74
- }
75
-
76
68
  /**
77
69
  * Detect training framework from config content.
78
70
  * Returns 'axolotl' | 'unsloth' | 'trl' | 'generic'.
@@ -155,20 +147,24 @@ export async function trainLoraCommand(config, args, chalk) {
155
147
  if (flags.dryRun) {
156
148
  const preset = flags.preset || 'small';
157
149
  const presetInfo = LORA_PRESET_INFO[preset];
150
+ const resolvedGpu = flags.gpuType || presetInfo?.gpu_type;
158
151
  console.log(chalk.bold('\n⚡ Dry run — no GPU will be provisioned\n'));
159
152
  console.log(` ${chalk.bold('Base model:')} ${flags.baseModel}`);
160
153
  console.log(` ${chalk.bold('Dataset:')} ${flags.fileId || flags.dataset || chalk.dim('(none given)')}`);
161
154
  console.log(` ${chalk.bold('Preset:')} ${preset}${presetInfo ? '' : chalk.yellow(' (unknown — server will reject this)')}`);
162
155
  if (presetInfo) {
163
- console.log(` ${chalk.bold('GPU:')} ${flags.gpuType || presetInfo.gpu_type}`);
156
+ console.log(` ${chalk.bold('GPU:')} ${resolvedGpu}`);
164
157
  console.log(` ${chalk.bold('LoRA rank:')} ${presetInfo.rank}`);
165
158
  console.log(` ${chalk.bold('Epochs:')} ${presetInfo.epochs}`);
166
159
  console.log(` ${chalk.dim(presetInfo.description)}`);
167
160
  }
161
+ console.log(` ${chalk.bold('Tier:')} ${formatTierLabel(normalizeTier(flags.tier))}`);
168
162
  if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost}`);
169
163
  console.log(` ${chalk.bold('Max runtime:')} ${flags.maxRuntime ?? 240}min`);
170
164
  if (flags.resume) console.log(` ${chalk.bold('Resume from:')} ${flags.resume}`);
171
- console.log(chalk.dim('\n Remove --dry-run to submit (local file datasets are uploaded first).\n'));
165
+ console.log();
166
+ if (resolvedGpu) await printCapacityPreview(chalk, config, { gpu: resolvedGpu });
167
+ console.log(chalk.dim(' Remove --dry-run to submit (local file datasets are uploaded first).\n'));
172
168
  return;
173
169
  }
174
170
 
@@ -10,6 +10,8 @@ import { requireApiKey } from '../config.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';
13
+ import { parseEnvFlag } from '../envFlag.js';
14
+ import { printCapacityPreview, formatTierLabel } from '../progress.js';
13
15
 
14
16
  const WHISPER_IMAGE = 'fedirz/faster-whisper-server:latest-cuda';
15
17
  const DEFAULT_WHISPER_MODEL = 'large-v3';
@@ -32,6 +34,7 @@ export function parseTranscribeArgs(args) {
32
34
  if (a === '--language') { flags.language = args[++i]; i++; continue; }
33
35
  if (a === '--output') { flags.output = args[++i]; i++; continue; }
34
36
  if (a === '--detach') { flags.detach = true; i++; continue; }
37
+ if (a === '--dry-run') { flags.dryRun = true; i++; continue; }
35
38
  if (a === '--env') {
36
39
  const kv = args[++i]; i++;
37
40
  if (!flags.env) flags.env = [];
@@ -43,15 +46,6 @@ export function parseTranscribeArgs(args) {
43
46
  return { input: positional[0] || null, flags };
44
47
  }
45
48
 
46
- function parseEnvFlag(envList) {
47
- const obj = {};
48
- for (const kv of (envList || [])) {
49
- const idx = kv.indexOf('=');
50
- if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
51
- }
52
- return obj;
53
- }
54
-
55
49
  function isUrl(s) {
56
50
  return /^(https?|s3|gs|hf):\/\//i.test(s);
57
51
  }
@@ -116,15 +110,24 @@ export async function transcribeCommand(config, args, chalk) {
116
110
  ...(flags.output ? { WHISPER_OUTPUT_FORMAT: flags.output } : {}),
117
111
  };
118
112
 
119
- console.log(chalk.bold('\n🎙 Transcription\n'));
113
+ console.log(chalk.bold(flags.dryRun ? '\n🎙 Dry run — no GPU will be provisioned — Transcription\n' : '\n🎙 Transcription\n'));
120
114
  console.log(` ${chalk.bold('Input:')} ${resolved.inputLabel}`);
121
115
  console.log(` ${chalk.bold('Model:')} ${whisperModel}`);
122
116
  console.log(` ${chalk.bold('Image:')} ${WHISPER_IMAGE}`);
123
117
  console.log(` ${chalk.bold('GPU:')} ${gpu === 'AUTO' ? chalk.dim('auto (8+ GB VRAM)') : gpu}`);
118
+ if (flags.dryRun) console.log(` ${chalk.bold('Tier:')} ${formatTierLabel(effectiveTier)}`);
124
119
  console.log(` ${chalk.bold('Max runtime:')} ${maxRuntimeMin}min`);
125
120
  if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
126
121
  if (flags.language) console.log(` ${chalk.bold('Language:')} ${flags.language}`);
122
+ if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
127
123
  console.log();
124
+
125
+ if (flags.dryRun) {
126
+ await printCapacityPreview(chalk, config, { gpu, region: flags.region?.toUpperCase(), maxPrice: flags.maxPrice });
127
+ console.log(chalk.dim(' Remove --dry-run to submit.\n'));
128
+ return;
129
+ }
130
+
128
131
  process.stdout.write(chalk.dim(' Finding suitable capacity...\n'));
129
132
 
130
133
  function buildBody(tierOverride) {
@@ -8,10 +8,43 @@ export const CREDENTIALS_FILE = join(CONFIG_DIR, 'credentials.json');
8
8
  export const PROVIDER_ENV_KEYS = {
9
9
  anthropic: 'ANTHROPIC_API_KEY',
10
10
  openai: 'OPENAI_API_KEY',
11
+ // OpenAI-compatible BYOK lanes for `badgr launch cline --provider <name>`
12
+ // (see MODEL_PROVIDERS below) — the cline agent image only ever reads
13
+ // OPENAI_API_KEY/OPENAI_BASE_URL/MODEL (images/badgr-agent-cline/
14
+ // badgr-cline-run), regardless of which upstream provider the key
15
+ // actually belongs to.
16
+ openrouter: 'OPENAI_API_KEY',
17
+ deepseek: 'OPENAI_API_KEY',
18
+ glm: 'OPENAI_API_KEY',
19
+ custom: 'OPENAI_API_KEY',
11
20
  };
12
21
 
13
22
  export const KNOWN_PROVIDERS = Object.keys(PROVIDER_ENV_KEYS);
14
23
 
24
+ // `badgr launch cline --provider <name> --model <id> [--base-url <url>]` —
25
+ // BYOK / OpenAI-compatible model selection. `custom` has no default base
26
+ // URL: it must always be supplied explicitly, since there is nothing sane
27
+ // to default it to. Every other entry's defaultBaseUrl is overridable with
28
+ // an explicit --base-url too (e.g. a company-hosted DeepSeek-compatible
29
+ // gateway).
30
+ export const MODEL_PROVIDERS = {
31
+ openrouter: { label: 'OpenRouter', defaultBaseUrl: 'https://openrouter.ai/api/v1' },
32
+ deepseek: { label: 'DeepSeek', defaultBaseUrl: 'https://api.deepseek.com/v1' },
33
+ glm: { label: 'GLM (Zhipu)', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4' },
34
+ custom: { label: 'Custom (OpenAI-compatible)', defaultBaseUrl: null },
35
+ };
36
+
37
+ export const KNOWN_MODEL_PROVIDERS = Object.keys(MODEL_PROVIDERS);
38
+
39
+ // Which env vars each API kind expects inside the agent container.
40
+ // `claude` uses the Anthropic SDK (ANTHROPIC_*); `cline` and `codex` use the
41
+ // OpenAI SDK (OPENAI_*). `model` is always 'MODEL' — every agent wrapper
42
+ // reads that env var and maps it to its own --model flag.
43
+ export const ENV_KEYS_FOR_API_KIND = {
44
+ openai: { apiKey: 'OPENAI_API_KEY', baseUrl: 'OPENAI_BASE_URL', model: 'MODEL' },
45
+ anthropic: { apiKey: 'ANTHROPIC_API_KEY', baseUrl: 'ANTHROPIC_BASE_URL', model: 'MODEL' },
46
+ };
47
+
15
48
  /**
16
49
  * Credential storage for `badgr connect <provider>`. This is a local file
17
50
  * under ~/.badgr with owner-only permissions (chmod 600) — not the
package/src/envFlag.js ADDED
@@ -0,0 +1,10 @@
1
+ // Shared by every command that accepts repeated `--env KEY=VALUE` flags
2
+ // (run, serve, comfyui, train, transcribe, embed).
3
+ export function parseEnvFlag(envList) {
4
+ const obj = {};
5
+ for (const kv of (envList || [])) {
6
+ const idx = kv.indexOf('=');
7
+ if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
8
+ }
9
+ return obj;
10
+ }
package/src/fallback.js CHANGED
@@ -6,6 +6,13 @@ import { CATALOG, formatCliError } from './errors.js';
6
6
  /** Rates above this threshold trigger a visible warning when no --max-cost is set. */
7
7
  export const HIGH_RATE_THRESHOLD = 3.00;
8
8
 
9
+ // ── badgr run --smoke defaults ────────────────────────────────────────────────
10
+ // Hardcoded default smoke caps, not a config system — --max-cost/--max-runtime
11
+ // still override them when passed explicitly (see run.js). The goal is to stop
12
+ // expensive local smoke tests by default, not build another configuration system.
13
+ export const SMOKE_MAX_COST_USD = 0.25;
14
+ export const SMOKE_MAX_RUNTIME_MINUTES = 10;
15
+
9
16
  /** Normalise --tier flag variants to '1' or '2'. */
10
17
  export function normalizeTier(tier) {
11
18
  return (tier === '2' || tier === 'tier2' || tier === 'tier-2') ? '2' : (tier || '1');
@@ -37,12 +44,16 @@ export class CapacityError extends Error {
37
44
  * @param {object} labels - { thing: 'job'|'endpoint', cmd: 'badgr run'|'badgr serve' }
38
45
  * @param {object} [opts]
39
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)
40
50
  */
41
51
  export async function callWithFallback(endpoint, callOpts, buildBody, effectiveTier, chalk, labels, opts = {}) {
42
52
  const { callApi } = await import('./api.js');
43
53
  const thing = labels?.thing ?? 'job';
44
54
  const cmd = labels?.cmd ?? 'badgr run';
45
- const allowTier2Fallback = opts.allowTier2Fallback !== false; // default true
55
+ const singleAttempt = opts.singleAttempt === true;
56
+ const allowTier2Fallback = !singleAttempt && opts.allowTier2Fallback !== false; // default true
46
57
 
47
58
  // 220s: comfortably above backend's BADGR_PROVISION_TIMEOUT_SECONDS (default
48
59
  // 200s, itself set above deployment_service.py's 180s routing-search
@@ -113,7 +124,7 @@ export async function callWithFallback(endpoint, callOpts, buildBody, effectiveT
113
124
  // Provider retry: PROVISIONING_FAILED means the selected provider couldn't launch the slot.
114
125
  // Retry once with prefer_different_provider so the backend routes to a different provider
115
126
  // (e.g. RunPod failed → try Vast.ai or Hyperstack) within the same max_cost budget.
116
- if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
127
+ if (!singleAttempt && (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR')) {
117
128
  console.log(chalk.dim('\n Provider unavailable — trying alternative provider...\n'));
118
129
  try {
119
130
  const retryBody = { ...buildBody(), prefer_different_provider: true };
package/src/onboarding.js CHANGED
@@ -50,7 +50,14 @@ async function pollCliSession(config, sessionId) {
50
50
  throw new Error('Timed out waiting for login in the browser. Run `badgr login` directly.');
51
51
  }
52
52
 
53
- async function ensureLoggedIn(config, chalk) {
53
+ /**
54
+ * Opens the browser-based login link and polls until it completes, saving
55
+ * the returned API key. Exported so `badgr login` (an explicit, standalone
56
+ * invocation) can reuse the exact same flow `ensureBadgrReady` triggers
57
+ * just-in-time from `run`/`launch`/`serve`/`comfyui`, rather than having a
58
+ * second, divergent login implementation.
59
+ */
60
+ export async function ensureLoggedIn(config, chalk) {
54
61
  console.log(chalk.dim("\n You're not logged in to Badgr."));
55
62
  console.log(chalk.dim(' Badgr uses prepaid credits to pay for the VM and model usage.\n'));
56
63
 
package/src/progress.js CHANGED
@@ -2,6 +2,8 @@
2
2
  // numbered stage lines plus an in-place-redrawing status block, so neither
3
3
  // command spams repeated identical loading lines.
4
4
 
5
+ import { getCapacityPreview } from './api.js';
6
+
5
7
  export function stage(n, total, label) {
6
8
  return ` [${n}/${total}] ${label}`;
7
9
  }
@@ -200,3 +202,49 @@ export function renderJobClosingBlock(chalk, detail, rcptId) {
200
202
 
201
203
  return lines.join('\n') + '\n';
202
204
  }
205
+
206
+ // Shared across every dry-run preview that shows routing tier — normalizeTier()
207
+ // already collapses anything else to 1, so this only ever distinguishes 1 vs 2.
208
+ export function formatTierLabel(tier) {
209
+ return tier === 2 ? '2 (marketplace)' : '1 (managed)';
210
+ }
211
+
212
+ /**
213
+ * Shared "live capacity preview" block for GPU-provisioning dry runs
214
+ * (badgr serve/run/comfyui/train/batch/sbatch --dry-run). Calls the
215
+ * no-provisioning GET /v1/capacity/suggestions route (api.js's
216
+ * getCapacityPreview) and prints an observed rate/alternatives — same shape
217
+ * `badgr up --dry-run` already showed, now reused everywhere else instead of
218
+ * every dry-run only echoing back the flags the user typed. Best-effort:
219
+ * a failed or empty lookup prints a one-line fallback, never blocks or
220
+ * throws, since a dry run must always finish even if live pricing can't be
221
+ * reached (offline, GPU not currently listed, transient API error, etc).
222
+ */
223
+ export async function printCapacityPreview(chalk, config, { gpu, region, maxPrice }) {
224
+ if (!gpu || gpu === 'AUTO' || gpu === 'NONE') {
225
+ console.log(chalk.dim(` GPU is auto-selected — resolved to a specific type and priced at provision time.`));
226
+ console.log();
227
+ return;
228
+ }
229
+ try {
230
+ const suggestions = await getCapacityPreview(config, { gpu, region, maxPrice });
231
+ const matches = suggestions?.matches ?? suggestions?.routes ?? [];
232
+ const alternatives = suggestions?.alternatives ?? [];
233
+ console.log(chalk.bold(' Live capacity'));
234
+ if (matches.length === 0) {
235
+ console.log(chalk.yellow(` No ${gpu} capacity found right now`));
236
+ } else {
237
+ const cheapest = matches[0];
238
+ console.log(` ${chalk.cyan(gpu)} available — from ${chalk.green('$' + cheapest.price.toFixed(2) + '/hr')} (${matches.length} offer(s), tier ${cheapest.tier})`);
239
+ }
240
+ if (alternatives.length > 0) {
241
+ alternatives.slice(0, 2).forEach(a => {
242
+ console.log(chalk.dim(` alt: ${a.gpu} in ${a.region} $${a.price.toFixed(2)}/hr${a.diff_desc ? ` — ${a.diff_desc}` : ''}`));
243
+ });
244
+ }
245
+ console.log();
246
+ } catch (err) {
247
+ console.log(chalk.dim(` Live capacity: could not fetch (${err.message})`));
248
+ console.log();
249
+ }
250
+ }