badgr-cli 1.0.40 → 1.0.41

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 CHANGED
@@ -17,11 +17,15 @@ badgr login
17
17
  # 2. Verify the stack end-to-end
18
18
  badgr test
19
19
 
20
- # 3. Serve an OpenAI-compatible inference endpoint
21
- badgr serve meta-llama/Llama-3.1-8B-Instruct
20
+ # 3. Run a project folder on a GPU
21
+ badgr run . --cmd "python train.py" --max-cost 5
22
22
 
23
- # 4. Use the endpoint with any OpenAI SDK client
24
- # client = OpenAI(api_key="sk-...", base_url="https://dep-a1b2c3.aibadgr.com/v1")
23
+ # 4. Serve an OpenAI-compatible inference endpoint
24
+ badgr serve meta-llama/Llama-3.1-8B-Instruct --max-cost 10
25
+
26
+ # 5. Use the endpoint with any OpenAI SDK client
27
+ # export BADGR_ENDPOINT=<URL printed by badgr serve>
28
+ # client = OpenAI(api_key=os.environ["BADGR_API_KEY"], base_url=os.environ["BADGR_ENDPOINT"])
25
29
 
26
30
  # 5. View cost, route, and retry receipts
27
31
  badgr receipts
@@ -92,25 +96,36 @@ badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S --region EU
92
96
 
93
97
  ## `badgr run` options
94
98
 
99
+ Three source patterns:
100
+
95
101
  ```bash
96
- badgr run python train.py --gpu A100 --env HF_TOKEN=$HF_TOKEN --max-runtime 60
102
+ # Flow 1 local project folder (primary)
103
+ badgr run . --cmd "python train.py" --max-cost 5
104
+
105
+ # Flow 2 — public GitHub repo
106
+ badgr run https://github.com/user/repo --cmd "python train.py" --max-cost 5
107
+
108
+ # Flow 3 — custom Docker image (advanced)
109
+ badgr run . --image mycompany/custom:latest --cmd "python train.py" --max-cost 5
97
110
  ```
98
111
 
112
+ Badgr zips and uploads the folder (Flow 1) or clones the repo (Flow 2), picks a generic runner, installs deps, runs the command, stores outputs for 48 hours, and tears down the GPU. `--max-cost` is required.
113
+
99
114
  | Flag | Default | Description |
100
115
  |------|---------|-------------|
116
+ | `--cmd <command>` | — | Command to run inside the uploaded project or cloned repo (required for folder/GitHub flows) |
101
117
  | `--gpu <type>` | auto | GPU type override — Badgr Auto selects if omitted |
102
118
  | `--min-vram <GB>` | — | Minimum VRAM in GB — optional constraint for Auto routing |
103
- | `--image <img>` | python:3.11-slim | Docker image |
119
+ | `--image <img>` | | Custom Docker image — bypasses the runner. Mutually exclusive with workspace auto-detection. |
104
120
  | `--env KEY=VALUE` | — | Environment variable (repeatable) |
105
121
  | `--tier 1\|2` | 1 | `1` = reliable execution (default); `2` = lower-cost burst capacity |
106
122
  | `--count <n>` | 1 | Number of GPUs |
107
123
  | `--region US\|EU\|AU` | — | Optional region preference. If omitted, Badgr chooses best available capacity. |
108
124
  | `--max-price <$/hr>` | — | Hard spend cap per GPU-hour |
109
- | `--max-runtime <min>` | — | Auto-stop after N minutes (recommended) |
110
- | `--max-cost <$>` | — | Auto-stop when total spend reaches this amount |
125
+ | `--max-runtime <min>` | — | Auto-stop after N minutes |
126
+ | `--max-cost <$>` | — | Auto-stop when total spend reaches this amount (required) |
111
127
  | `--detach` | — | Launch and return immediately, don't stream logs |
112
128
  | `--save <name>` | — | Save this job as a named workload after it completes |
113
- | `--workspace <name\|id>` | — | Link this job to a workspace tracker (name or `ws_…` ID) |
114
129
 
115
130
  ---
116
131
 
@@ -216,7 +231,7 @@ A workload is a saved job configuration. Once saved, you can rerun it by name in
216
231
  **Save a workload** by adding `--save <name>` to any `badgr run` call:
217
232
 
218
233
  ```bash
219
- badgr run python train.py --gpu A100 --env HF_TOKEN=$HF_TOKEN --max-runtime 120 --save my-training-job
234
+ badgr run . --cmd "python train.py" --gpu A100 --env HF_TOKEN=$HF_TOKEN --max-cost 10 --save my-training-job
220
235
  ```
221
236
 
222
237
  **Rerun a saved workload:**
@@ -245,11 +260,11 @@ badgr workload delete my-training-job
245
260
 
246
261
  ## Workspaces
247
262
 
248
- A workspace tracker groups jobs by a named context useful for tracking cost and job history across a project or storage path.
263
+ Workspaces are an advanced infrastructure concept most users never need to manage them directly. When you run `badgr run .`, Badgr handles code upload, dependency caching, and artifact storage automatically. Workspaces are only needed when you want to group jobs under a named context for cost tracking, or link jobs to persistent S3/GCS storage.
249
264
 
250
265
  ```bash
251
266
  badgr workspace create my-project --storage s3://my-bucket/runs --desc "nightly evals"
252
- badgr run python eval.py --workspace my-project --max-cost 5
267
+ badgr run . --cmd "python eval.py" --workspace my-project --max-cost 5
253
268
  badgr workspace info my-project # jobs, total cost, files
254
269
  badgr workspace list
255
270
  badgr workspace delete my-project
@@ -298,11 +313,13 @@ Each receipt includes: receipt ID, GPU type, provisioning latency, price/hr, ret
298
313
  `badgr serve` provisions a vLLM endpoint that is fully OpenAI-compatible:
299
314
 
300
315
  ```python
316
+ import os
301
317
  from openai import OpenAI
302
318
 
319
+ # Export BADGR_ENDPOINT from the URL printed by `badgr serve`
303
320
  client = OpenAI(
304
- api_key="your-badgr-api-key",
305
- base_url="https://dep-a1b2c3.aibadgr.com/v1", # from badgr serve output
321
+ api_key=os.environ["BADGR_API_KEY"],
322
+ base_url=os.environ["BADGR_ENDPOINT"],
306
323
  )
307
324
  resp = client.chat.completions.create(
308
325
  model="meta-llama/Llama-3.1-8B-Instruct",
@@ -314,7 +331,7 @@ resp = client.chat.completions.create(
314
331
  import OpenAI from "openai";
315
332
  const client = new OpenAI({
316
333
  apiKey: process.env.BADGR_API_KEY,
317
- baseURL: "https://dep-a1b2c3.aibadgr.com/v1",
334
+ baseURL: process.env.BADGR_ENDPOINT, // URL printed by `badgr serve`
318
335
  });
319
336
  ```
320
337
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "badgr-cli",
3
- "version": "1.0.40",
3
+ "version": "1.0.41",
4
4
  "description": "Badgr — run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,8 +12,8 @@
12
12
  "test:watch": "vitest"
13
13
  },
14
14
  "dependencies": {
15
- "chalk": "^5.3.0",
16
- "@inquirer/prompts": "^8.5.2"
15
+ "@inquirer/prompts": "^8.5.2",
16
+ "chalk": "^5.3.0"
17
17
  },
18
18
  "devDependencies": {
19
19
  "vitest": "^4.1.8"
package/src/catalog.js CHANGED
@@ -40,6 +40,30 @@ export const TEMPLATES = [
40
40
  'Pass HF_TOKEN via --env or BADGR_HF_TOKEN env var.',
41
41
  ],
42
42
  },
43
+ {
44
+ name: 'batch-inference',
45
+ title: 'Batch Inference',
46
+ description: 'Run a Python script against many inputs on GPU — eval, scoring, embedding, generation',
47
+ type: 'job',
48
+ image: 'pytorch/pytorch:2.3.0-cuda12.1-cudnn8-runtime',
49
+ gpu: 'RTX_4090',
50
+ gpu_count: 1,
51
+ min_vram_gb: 16,
52
+ env: {
53
+ HF_TOKEN: '<your-hf-token>',
54
+ INPUT_FILE: 'inputs.jsonl',
55
+ OUTPUT_FILE: 'outputs.jsonl',
56
+ MODEL: 'meta-llama/Llama-3.1-8B-Instruct',
57
+ HF_HUB_CACHE: '/workspace/hf-cache',
58
+ BADGR_OUTPUT_DIR: '/workspace/outputs',
59
+ },
60
+ notes: [
61
+ 'Put your script and inputs.jsonl in the project folder.',
62
+ 'Write outputs to $BADGR_OUTPUT_DIR — they are uploaded as artifacts (48h TTL).',
63
+ 'Download results: `badgr artifacts <job-id>`',
64
+ 'Example: `badgr run . --cmd "python batch.py" --max-cost 5 --save my-batch`',
65
+ ],
66
+ },
43
67
  {
44
68
  name: 'unsloth',
45
69
  title: 'Unsloth Fine-Tuning',
@@ -1,3 +1,7 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { createWriteStream } from 'fs';
1
5
  import { requireApiKey } from '../config.js';
2
6
  import { callApi, terminateDeployment } from '../api.js';
3
7
  import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
@@ -6,10 +10,19 @@ import { formatCliError } from '../errors.js';
6
10
  import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides } from '../catalog.js';
7
11
 
8
12
  /**
9
- * badgr run python train.py # gpu=auto, attached
10
- * badgr run python train.py --gpu A100 # specific GPU
11
- * badgr run --image my/image:latest --gpu L40S --detach
12
- * badgr run python train.py --env HF_TOKEN=abc --env DATASET=my/data
13
+ * Flow 1 local project (primary):
14
+ * badgr run . --cmd "python train.py" --max-cost 5 --save my-training-job
15
+ * badgr run ./my-project --cmd "python train.py" --max-cost 5
16
+ *
17
+ * Flow 2 — public GitHub repo:
18
+ * badgr run https://github.com/user/repo --cmd "python train.py" --max-cost 5
19
+ *
20
+ * Flow 3 — custom image (advanced):
21
+ * badgr run . --image mycompany/custom:latest --cmd "python train.py" --max-cost 5
22
+ *
23
+ * Legacy / direct command:
24
+ * badgr run python train.py --gpu A100
25
+ * badgr run --image my/image:latest --gpu L40S --detach
13
26
  */
14
27
  export function parseRunArgs(args) {
15
28
  const flags = {};
@@ -41,6 +54,7 @@ export function parseRunArgs(args) {
41
54
  if (flagArgs[i] === '--dry-run') { flags.dryRun = true; i++; continue; }
42
55
  if (flagArgs[i] === '--save') { flags.save = flagArgs[++i]; i++; continue; }
43
56
  if (flagArgs[i] === '--workspace') { flags.workspace = flagArgs[++i]; i++; continue; }
57
+ if (flagArgs[i] === '--cmd') { flags.cmd = flagArgs[++i]; i++; continue; }
44
58
  if (flagArgs[i] === '--env') {
45
59
  const kv = flagArgs[++i]; i++;
46
60
  if (!flags.env) flags.env = [];
@@ -342,9 +356,117 @@ const _KNOWN_RUN_FLAGS = new Set([
342
356
  '--gpu', '--image', '--count', '--region', '--tier', '--max-price', '--name',
343
357
  '--detach', '--fallback', '--no-fallback', '--strict-capacity',
344
358
  '--no-expanded-search', '--max-runtime', '--max-cost', '--min-vram', '--env',
345
- '--dry-run',
359
+ '--dry-run', '--cmd', '--save', '--workspace',
346
360
  ]);
347
361
 
362
+ // Directories and files always excluded from project zip uploads.
363
+ const _ZIP_EXCLUDES = new Set([
364
+ '.git', 'node_modules', '__pycache__', '.venv', 'venv', '.env',
365
+ 'dist', 'build', '.next', '.nuxt', 'coverage', '.pytest_cache',
366
+ '.mypy_cache', '.ruff_cache', '.DS_Store',
367
+ ]);
368
+
369
+ function _shouldExclude(name) {
370
+ return _ZIP_EXCLUDES.has(name) || name.startsWith('.') && name !== '.gitignore';
371
+ }
372
+
373
+ function _isLocalPath(arg) {
374
+ // '.' or './' or relative/absolute paths that exist on disk
375
+ if (arg === '.') return true;
376
+ if (arg.startsWith('./') || arg.startsWith('../') || arg.startsWith('/')) return true;
377
+ return false;
378
+ }
379
+
380
+ function _isGitHubUrl(arg) {
381
+ return /^https?:\/\/(www\.)?github\.com\//.test(arg);
382
+ }
383
+
384
+ /**
385
+ * Zip a local directory into a temp file, returning the temp file path.
386
+ * Uses Node's built-in APIs — no external zip library required.
387
+ */
388
+ async function _zipDirectory(dirPath, chalk) {
389
+ // Use archiver if available, otherwise fall back to a manual approach via
390
+ // a child process calling `zip` (available on Linux/macOS) or PowerShell on Windows.
391
+ const absDir = path.resolve(dirPath);
392
+ if (!fs.existsSync(absDir)) {
393
+ throw new Error(`Directory not found: ${absDir}`);
394
+ }
395
+
396
+ const tmpFile = path.join(os.tmpdir(), `badgr-upload-${Date.now()}.zip`);
397
+
398
+ // Attempt to use archiver (optional peer dep) first; fall back to system zip.
399
+ try {
400
+ const { default: archiver } = await import('archiver');
401
+ await new Promise((resolve, reject) => {
402
+ const output = createWriteStream(tmpFile);
403
+ const archive = archiver('zip', { zlib: { level: 6 } });
404
+ output.on('close', resolve);
405
+ archive.on('error', reject);
406
+ archive.pipe(output);
407
+ archive.glob('**/*', {
408
+ cwd: absDir,
409
+ dot: false,
410
+ ignore: [..._ZIP_EXCLUDES].map(e => `**/${e}/**`).concat([..._ZIP_EXCLUDES].map(e => e)),
411
+ });
412
+ archive.finalize();
413
+ });
414
+ return tmpFile;
415
+ } catch {
416
+ // archiver not installed — fall back to system zip command
417
+ }
418
+
419
+ const { spawn } = await import('child_process');
420
+ await new Promise((resolve, reject) => {
421
+ // Build exclude args — zip uses -x patterns
422
+ const excludeArgs = [];
423
+ for (const ex of _ZIP_EXCLUDES) {
424
+ excludeArgs.push('-x', `*/${ex}/*`, '-x', `${ex}/*`, '-x', `${ex}`);
425
+ }
426
+ const child = spawn('zip', ['-r', '-q', tmpFile, '.', ...excludeArgs], { cwd: absDir });
427
+ child.on('close', code => code === 0 ? resolve() : reject(new Error(`zip exited ${code}`)));
428
+ child.on('error', reject);
429
+ });
430
+
431
+ return tmpFile;
432
+ }
433
+
434
+ export async function _uploadCodeZip(config, dirPath, chalk) {
435
+ process.stdout.write(chalk.dim(' Packing project...'));
436
+ const tmpFile = await _zipDirectory(dirPath, chalk);
437
+ const stat = fs.statSync(tmpFile);
438
+ const sizeMb = (stat.size / 1024 / 1024).toFixed(1);
439
+ process.stdout.write(chalk.dim(` ${sizeMb} MB\n`));
440
+
441
+ process.stdout.write(chalk.dim(' Uploading project...'));
442
+ // POST zip directly to the Badgr backend — no S3, no presigned URLs.
443
+ // Built-in FormData/Blob/fetch (Node >=18) keep the CLI dependency-free for
444
+ // multipart uploads; fetch sets the multipart boundary header automatically.
445
+ const fileData = fs.readFileSync(tmpFile);
446
+ const form = new FormData();
447
+ form.append('file', new Blob([fileData], { type: 'application/zip' }), 'project.zip');
448
+
449
+ const baseUrl = config.baseUrl.replace(/\/v1\/?$/, '');
450
+ let uploadResp;
451
+ try {
452
+ uploadResp = await fetch(`${baseUrl}/v1/uploads`, {
453
+ method: 'POST',
454
+ body: form,
455
+ headers: { 'Authorization': `Bearer ${config.apiKey}` },
456
+ });
457
+ } finally {
458
+ fs.unlinkSync(tmpFile);
459
+ }
460
+
461
+ if (!uploadResp.ok) {
462
+ const text = await uploadResp.text().catch(() => '');
463
+ throw new Error(`Upload failed: ${uploadResp.status} ${uploadResp.statusText}${text ? ` — ${text}` : ''}`);
464
+ }
465
+ const { code_uri: codeUri } = await uploadResp.json();
466
+ process.stdout.write(chalk.dim(' done\n'));
467
+ return codeUri;
468
+ }
469
+
348
470
  export async function runCommand(config, args, chalk) {
349
471
  // `badgr run template <name> [flags]` — expand template defaults then re-dispatch
350
472
  if (args[0] === 'template') {
@@ -387,12 +509,37 @@ export async function runCommand(config, args, chalk) {
387
509
  return;
388
510
  }
389
511
 
390
- if (positional.length === 0 && commandArgv === null && !flags.image) {
391
- console.error(chalk.red('Usage: badgr run --gpu <GPU> --image <image> --max-cost <n> -- <command...>'));
392
- console.error(chalk.red(' badgr run --image my/image:latest'));
512
+ // ── Detect source type ─────────────────────────────────────────────────────
513
+ // positional[0] may be: '.' | './dir' | 'https://github.com/...' | 'python' | nothing
514
+ const firstArg = positional[0] ?? null;
515
+ const isLocalPath = firstArg && _isLocalPath(firstArg);
516
+ const isGitHubUrl = firstArg && _isGitHubUrl(firstArg);
517
+ const isCodeSource = isLocalPath || isGitHubUrl;
518
+
519
+ if (isCodeSource && !flags.cmd) {
520
+ console.error(chalk.red(`\n ✗ --cmd is required when running from a ${isLocalPath ? 'local path' : 'GitHub URL'}.\n`));
521
+ if (isLocalPath) {
522
+ console.error(chalk.dim(' Example: badgr run . --cmd "python train.py" --max-cost 5\n'));
523
+ } else {
524
+ console.error(chalk.dim(' Example: badgr run https://github.com/user/repo --cmd "python train.py" --max-cost 5\n'));
525
+ }
526
+ process.exitCode = 1;
527
+ return;
528
+ }
529
+
530
+ // Legacy direct-command mode: positional args form the command
531
+ const isDirectCommand = !isCodeSource && (positional.length > 0 || commandArgv !== null || flags.image);
532
+
533
+ if (!isCodeSource && positional.length === 0 && commandArgv === null && !flags.image) {
534
+ console.error(chalk.red('\nUsage:'));
535
+ console.error(chalk.dim(' badgr run . --cmd "python train.py" --max-cost 5'));
536
+ console.error(chalk.dim(' badgr run https://github.com/user/repo --cmd "python train.py" --max-cost 5'));
537
+ console.error(chalk.dim(' badgr run python train.py --max-cost 5'));
538
+ console.error(chalk.dim(' badgr run --image my/image:latest --max-cost 5'));
539
+ console.error('');
393
540
  return;
394
541
  }
395
- if (commandArgv !== null && commandArgv.length === 0 && !flags.image) {
542
+ if (commandArgv !== null && commandArgv.length === 0 && !flags.image && !isCodeSource) {
396
543
  console.error(chalk.red(' ✗ No command after --. Provide a command or --image.'));
397
544
  console.error(chalk.dim(' Example: badgr run --gpu RTX_4090 --max-cost 1 -- node script.js'));
398
545
  process.exitCode = 1;
@@ -430,13 +577,17 @@ export async function runCommand(config, args, chalk) {
430
577
  }
431
578
 
432
579
  // commandArgv is set when -- separator was used; fall back to positional for legacy syntax.
433
- const command = commandArgv !== null
434
- ? (commandArgv.length > 0 ? commandArgv : undefined)
435
- : (positional.length > 0 ? positional : undefined);
436
- const cmdStr = command ? command.join(' ') : '';
437
- const isSmoke = cmdStr.length < 80 && /print\s*\(|['"]hello/i.test(cmdStr);
438
- const inferredImage = isSmoke ? 'python:3.11-alpine' : 'python:3.11-slim';
439
- const image = flags.image || (command ? inferredImage : undefined);
580
+ // For code-source flows (local path / GitHub), the "command" field is empty — the runner
581
+ // receives it via BADGR_CMD env var instead.
582
+ const command = isCodeSource
583
+ ? undefined
584
+ : (commandArgv !== null
585
+ ? (commandArgv.length > 0 ? commandArgv : undefined)
586
+ : (positional.length > 0 ? positional : undefined));
587
+ const cmdStr = isCodeSource ? (flags.cmd || '') : (command ? command.join(' ') : '');
588
+ const isSmoke = !isCodeSource && cmdStr.length < 80 && /print\s*\(|['"]hello/i.test(cmdStr);
589
+ const inferredImage = isSmoke ? 'python:3.11-alpine' : undefined; // code-source flows use runner image
590
+ const image = flags.image || (command && !isCodeSource ? (isSmoke ? 'python:3.11-alpine' : undefined) : undefined);
440
591
  const detach = flags.detach || false;
441
592
 
442
593
  // Default max-runtime of 60 minutes — always applied unless overridden.
@@ -453,6 +604,9 @@ export async function runCommand(config, args, chalk) {
453
604
 
454
605
  if (flags.dryRun) {
455
606
  console.log(chalk.bold('\n⚡ Dry run — no GPU will be provisioned\n'));
607
+ if (isLocalPath) console.log(` ${chalk.bold('Source:')} ${path.resolve(firstArg)} (local project)`);
608
+ if (isGitHubUrl) console.log(` ${chalk.bold('Source:')} ${firstArg} (GitHub)`);
609
+ if (flags.cmd) console.log(` ${chalk.bold('Command:')} ${flags.cmd}`);
456
610
  if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
457
611
  if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
458
612
  console.log(` ${chalk.bold('GPU:')} ${gpu || chalk.dim('auto')}`);
@@ -465,10 +619,13 @@ export async function runCommand(config, args, chalk) {
465
619
  }
466
620
 
467
621
  console.log(chalk.bold('\n⚡ Running GPU job\n'));
468
- if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
469
- if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
470
- if (gpu) console.log(` ${chalk.bold('GPU:')} ${gpu}`);
471
- else console.log(` ${chalk.bold('GPU:')} ${chalk.dim('auto')}`);
622
+ if (isLocalPath) console.log(` ${chalk.bold('Source:')} ${path.resolve(firstArg)}`);
623
+ if (isGitHubUrl) console.log(` ${chalk.bold('Source:')} ${firstArg}`);
624
+ if (flags.cmd) console.log(` ${chalk.bold('Command:')} ${flags.cmd}`);
625
+ if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
626
+ if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
627
+ if (gpu) console.log(` ${chalk.bold('GPU:')} ${gpu}`);
628
+ else console.log(` ${chalk.bold('GPU:')} ${chalk.dim('auto')}`);
472
629
  if (flags.minVram) console.log(` ${chalk.bold('Min VRAM:')} ${flags.minVram} GB`);
473
630
  const runtimeLabel = isDefaultRuntime
474
631
  ? `${effectiveMaxRuntime}min ${chalk.dim('(default — use --max-runtime N to override)')}`
@@ -498,6 +655,18 @@ export async function runCommand(config, args, chalk) {
498
655
  }
499
656
  }
500
657
 
658
+ // ── Upload local project zip (Flow 1) ─────────────────────────────────────
659
+ let codeUri = null;
660
+ if (isLocalPath) {
661
+ try {
662
+ codeUri = await _uploadCodeZip(config, firstArg, chalk);
663
+ } catch (err) {
664
+ console.error(chalk.red(`\n ✗ Failed to upload project: ${err.message}\n`));
665
+ process.exitCode = 1;
666
+ return;
667
+ }
668
+ }
669
+
501
670
  console.log(chalk.dim(' Finding suitable capacity...'));
502
671
  if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
503
672
  console.log(chalk.dim(` API: ${config.baseUrl}`));
@@ -506,8 +675,8 @@ export async function runCommand(config, args, chalk) {
506
675
  function buildBody(gpuOverride, tierOverride) {
507
676
  const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
508
677
  return {
509
- command,
510
- image,
678
+ ...(command ? { command } : {}),
679
+ ...(image ? { image } : {}),
511
680
  gpu: gpuOverride || gpu || 'auto',
512
681
  ...(flags.minVram ? { min_vram: flags.minVram } : {}),
513
682
  gpu_count: flags.count || 1,
@@ -519,6 +688,10 @@ export async function runCommand(config, args, chalk) {
519
688
  max_runtime_seconds: effectiveMaxRuntime * 60,
520
689
  ...(maxCost ? { max_cost_usd: maxCost } : {}),
521
690
  ...(resolvedWorkspaceId ? { workspace_id: resolvedWorkspaceId } : {}),
691
+ // Code-source fields
692
+ ...(codeUri ? { code_uri: codeUri } : {}),
693
+ ...(isGitHubUrl ? { github_url: firstArg } : {}),
694
+ ...(flags.cmd ? { cmd: flags.cmd } : {}),
522
695
  };
523
696
  }
524
697
 
@@ -720,8 +893,12 @@ export async function runCommand(config, args, chalk) {
720
893
  name: flags.save,
721
894
  job_type: 'custom.run',
722
895
  config: {
723
- command: command || [],
724
- image: image || 'python:3.11-slim',
896
+ ...(command ? { command } : {}),
897
+ ...(image ? { image } : {}),
898
+ ...(flags.cmd ? { cmd: flags.cmd } : {}),
899
+ // For local-path workloads, code_uri is a snapshot — user can re-upload on next run.
900
+ // GitHub URL is stable and stored directly.
901
+ ...(isGitHubUrl ? { github_url: firstArg } : {}),
725
902
  gpu: gpu || 'auto',
726
903
  ...(flags.minVram ? { min_vram: flags.minVram } : {}),
727
904
  gpu_count: flags.count || 1,
@@ -104,8 +104,8 @@ function _serveStageLabel(elapsedSec, healthPath = '/models') {
104
104
  return 'Waiting for /v1/models…';
105
105
  }
106
106
  if (healthPath === '/health') {
107
- if (elapsedSec < 60) return 'Starting llama-server…';
108
- if (elapsedSec < 180) return 'Downloading from Hugging Face…';
107
+ if (elapsedSec < 60) return 'Starting server…';
108
+ if (elapsedSec < 180) return 'Downloading model…';
109
109
  return 'Waiting for /health…';
110
110
  }
111
111
  if (elapsedSec < 30) return 'Starting container…';
@@ -461,14 +461,19 @@ export async function serveCommand(config, args, chalk) {
461
461
  }
462
462
 
463
463
  // ── Determine health check path ───────────────────────────────────────────
464
- // Priority: explicit --health-path > llama.cpp → /health > vLLM → /models > auto-detect custom image > null
464
+ // Priority: explicit --health-path > llama.cpp → /health > task-specific > vLLM → /models > auto-detect custom image > null
465
465
  let resolvedHealthPath;
466
466
  if (flags.healthPath) {
467
467
  resolvedHealthPath = flags.healthPath;
468
468
  } else if (isLlamaCpp) {
469
469
  resolvedHealthPath = '/health';
470
470
  } else if (!customImage) {
471
- resolvedHealthPath = '/models';
471
+ // Managed runtimes for transcribe/image expose /health; vLLM (chat, embed) uses /models
472
+ if (flags.task === 'transcribe' || flags.task === 'image') {
473
+ resolvedHealthPath = '/health';
474
+ } else {
475
+ resolvedHealthPath = '/models';
476
+ }
472
477
  } else {
473
478
  resolvedHealthPath = _detectHealthPath(customImage); // '/system_stats' for comfyui, null otherwise
474
479
  }
@@ -573,7 +578,17 @@ export async function serveCommand(config, args, chalk) {
573
578
  console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
574
579
  console.log(chalk.dim(` from openai import OpenAI`));
575
580
  console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${keySnip}...")`));
576
- console.log(chalk.dim(` resp = client.chat.completions.create(model="${sdkModel}", messages=[...])`));
581
+ if (flags.task === 'transcribe') {
582
+ console.log(chalk.dim(` with open("audio.mp3", "rb") as f:`));
583
+ console.log(chalk.dim(` t = client.audio.transcriptions.create(model="${sdkModel}", file=f, response_format="text")`));
584
+ } else if (flags.task === 'image') {
585
+ console.log(chalk.dim(` resp = client.images.generate(model="${sdkModel}", prompt="...", n=1, size="1024x1024")`));
586
+ console.log(chalk.dim(` # resp.data[0].b64_json contains the base64-encoded PNG`));
587
+ } else if (flags.task === 'embed') {
588
+ console.log(chalk.dim(` resp = client.embeddings.create(model="${sdkModel}", input=["hello world"])`));
589
+ } else {
590
+ console.log(chalk.dim(` resp = client.chat.completions.create(model="${sdkModel}", messages=[{"role": "user", "content": "Hello"}])`));
591
+ }
577
592
  console.log();
578
593
  }
579
594
  }
package/src/fallback.js CHANGED
@@ -105,12 +105,14 @@ export async function callWithFallback(endpoint, callOpts, buildBody, effectiveT
105
105
 
106
106
  const d = firstErr.errorData;
107
107
 
108
- // Stale-capacity retry: PROVISIONING_FAILED means a GPU slot appeared available but
109
- // couldn't launch. Retry once immediately to pick up a fresh slot before escalating.
108
+ // Provider retry: PROVISIONING_FAILED means the selected provider couldn't launch the slot.
109
+ // Retry once with prefer_different_provider so the backend routes to a different provider
110
+ // (e.g. RunPod failed → try Vast.ai or Hyperstack) within the same max_cost budget.
110
111
  if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
111
- console.log(chalk.dim('\n Provisioning failedretrying with fresh capacity...\n'));
112
+ console.log(chalk.dim('\n Provider unavailabletrying alternative provider...\n'));
112
113
  try {
113
- return await attempt(buildBody());
114
+ const retryBody = { ...buildBody(), prefer_different_provider: true };
115
+ return await attempt(retryBody);
114
116
  } catch (retryErr) {
115
117
  if (retryErr.isPaymentRequired) throw retryErr;
116
118
  firstErr = retryErr;
@@ -355,6 +355,31 @@ describe('stale-capacity retry on PROVISIONING_FAILED', () => {
355
355
  expect(api.callApi.mock.calls.length).toBeGreaterThanOrEqual(2);
356
356
  });
357
357
 
358
+ it('sends prefer_different_provider=true on the PROVISIONING_FAILED retry', async () => {
359
+ api.callApi.mockRejectedValue(makeProvisioningFailedErr());
360
+
361
+ const bodies = [];
362
+ api.callApi.mockImplementation((_ep, opts) => {
363
+ bodies.push(opts.body);
364
+ return Promise.reject(makeProvisioningFailedErr());
365
+ });
366
+
367
+ try {
368
+ await callWithFallback(
369
+ '/run',
370
+ { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' },
371
+ () => ({ gpu: 'RTX_4090' }),
372
+ '2',
373
+ chalk,
374
+ { thing: 'job', cmd: 'badgr run' },
375
+ );
376
+ } catch (_) {}
377
+
378
+ // First call: no prefer_different_provider; retry call: must include it
379
+ expect(bodies[0]).not.toHaveProperty('prefer_different_provider');
380
+ expect(bodies[1]).toMatchObject({ prefer_different_provider: true });
381
+ });
382
+
358
383
  it('does NOT double-retry on non-PROVISIONING_FAILED errors', async () => {
359
384
  const err = new Error('NO_CAPACITY_MATCH');
360
385
  err.errorData = { code: 'NO_CAPACITY_MATCH' };
@@ -663,3 +663,117 @@ describe('llama.cpp E2E smoke — tiny GGUF (ggml-org/tiny-llamas)', () => {
663
663
  expect(process.exitCode).toBeFalsy();
664
664
  });
665
665
  });
666
+
667
+ // ─────────────────────────────────────────────────────────────────────────────
668
+ // 13. --task routing: transcribe and image use /health; embed uses /models
669
+ // ─────────────────────────────────────────────────────────────────────────────
670
+
671
+ describe('--task routing for managed runtimes', () => {
672
+ it('--task transcribe polls /health instead of /models', async () => {
673
+ api.callApi
674
+ .mockResolvedValueOnce(makeServeDep({ model: 'large-v3' })) // POST /serve
675
+ .mockResolvedValueOnce({ status: 'running' }); // pre-health dep check
676
+
677
+ const fetchedUrls = [];
678
+ global.fetch = vi.fn().mockImplementation((url) => {
679
+ fetchedUrls.push(url);
680
+ return Promise.resolve({ ok: true, status: 200 });
681
+ });
682
+
683
+ const p = serveCommand(
684
+ config,
685
+ ['large-v3', '--task', 'transcribe', '--max-cost', '5'],
686
+ chalk,
687
+ );
688
+ await vi.advanceTimersByTimeAsync(5000);
689
+ await p;
690
+
691
+ expect(fetchedUrls.some(u => u.includes('/health'))).toBe(true);
692
+ expect(fetchedUrls.some(u => u.endsWith('/models'))).toBe(false);
693
+ expect(process.exitCode).toBeFalsy();
694
+ });
695
+
696
+ it('--task transcribe sends task field to backend', async () => {
697
+ api.callApi.mockResolvedValueOnce(makeServeDep({ model: 'large-v3' }));
698
+ global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
699
+
700
+ const p = serveCommand(
701
+ config,
702
+ ['large-v3', '--task', 'transcribe', '--max-cost', '5', '--no-wait'],
703
+ chalk,
704
+ );
705
+ await vi.advanceTimersByTimeAsync(100);
706
+ await p;
707
+
708
+ const body = api.callApi.mock.calls[0][1].body;
709
+ expect(body.task).toBe('transcribe');
710
+ expect(body.model).toBe('large-v3');
711
+ expect(process.exitCode).toBeFalsy();
712
+ });
713
+
714
+ it('--task image polls /health instead of /models', async () => {
715
+ api.callApi
716
+ .mockResolvedValueOnce(makeServeDep({ model: 'black-forest-labs/FLUX.1-schnell' }))
717
+ .mockResolvedValueOnce({ status: 'running' });
718
+
719
+ const fetchedUrls = [];
720
+ global.fetch = vi.fn().mockImplementation((url) => {
721
+ fetchedUrls.push(url);
722
+ return Promise.resolve({ ok: true, status: 200 });
723
+ });
724
+
725
+ const p = serveCommand(
726
+ config,
727
+ ['black-forest-labs/FLUX.1-schnell', '--task', 'image', '--max-cost', '10'],
728
+ chalk,
729
+ );
730
+ await vi.advanceTimersByTimeAsync(5000);
731
+ await p;
732
+
733
+ expect(fetchedUrls.some(u => u.includes('/health'))).toBe(true);
734
+ expect(fetchedUrls.some(u => u.endsWith('/models'))).toBe(false);
735
+ expect(process.exitCode).toBeFalsy();
736
+ });
737
+
738
+ it('--task image sends task field to backend', async () => {
739
+ api.callApi.mockResolvedValueOnce(makeServeDep({ model: 'black-forest-labs/FLUX.1-schnell' }));
740
+ global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
741
+
742
+ const p = serveCommand(
743
+ config,
744
+ ['black-forest-labs/FLUX.1-schnell', '--task', 'image', '--max-cost', '10', '--no-wait'],
745
+ chalk,
746
+ );
747
+ await vi.advanceTimersByTimeAsync(100);
748
+ await p;
749
+
750
+ const body = api.callApi.mock.calls[0][1].body;
751
+ expect(body.task).toBe('image');
752
+ expect(body.model).toBe('black-forest-labs/FLUX.1-schnell');
753
+ expect(process.exitCode).toBeFalsy();
754
+ });
755
+
756
+ it('--task embed still polls /models (vLLM path)', async () => {
757
+ api.callApi
758
+ .mockResolvedValueOnce(makeServeDep({ model: 'BAAI/bge-large-en-v1.5' }))
759
+ .mockResolvedValueOnce({ status: 'running' });
760
+
761
+ const fetchedUrls = [];
762
+ global.fetch = vi.fn().mockImplementation((url) => {
763
+ fetchedUrls.push(url);
764
+ return Promise.resolve({ ok: true, status: 200 });
765
+ });
766
+
767
+ const p = serveCommand(
768
+ config,
769
+ ['BAAI/bge-large-en-v1.5', '--task', 'embed', '--max-cost', '5'],
770
+ chalk,
771
+ );
772
+ await vi.advanceTimersByTimeAsync(5000);
773
+ await p;
774
+
775
+ expect(fetchedUrls.some(u => u.endsWith('/models'))).toBe(true);
776
+ expect(fetchedUrls.some(u => u.includes('/health'))).toBe(false);
777
+ expect(process.exitCode).toBeFalsy();
778
+ });
779
+ });
@@ -105,14 +105,14 @@ afterEach(() => {
105
105
 
106
106
  describe('TEMPLATES catalog', () => {
107
107
  const EXPECTED_NAMES = [
108
- 'comfyui', 'axolotl', 'unsloth', 'vllm', 'llama-cpp',
108
+ 'comfyui', 'axolotl', 'batch-inference', 'unsloth', 'vllm', 'llama-cpp',
109
109
  'invokeai', 'kohya-ss', 'text-gen-webui', 'sglang', 'tgi',
110
110
  'auto1111', 'forge', 'nerfstudio', 'openfold', 'blender-render',
111
111
  'openmm', 'gromacs', 'lammps', 'diffusers', 'torchtune',
112
112
  ];
113
113
 
114
- it('contains exactly 20 templates', () => {
115
- expect(TEMPLATES).toHaveLength(20);
114
+ it('contains exactly 21 templates', () => {
115
+ expect(TEMPLATES).toHaveLength(21);
116
116
  });
117
117
 
118
118
  it('contains all expected template names', () => {
@@ -180,7 +180,7 @@ describe('TEMPLATES catalog', () => {
180
180
  for (const [k, t] of Object.entries(TEMPLATE_MAP)) {
181
181
  expect(k).toBe(t.name);
182
182
  }
183
- expect(Object.keys(TEMPLATE_MAP)).toHaveLength(20);
183
+ expect(Object.keys(TEMPLATE_MAP)).toHaveLength(21);
184
184
  });
185
185
  });
186
186
 
@@ -0,0 +1,79 @@
1
+ /**
2
+ * badgr run — local-project upload flow (regression).
3
+ *
4
+ * Guards the bug where `_uploadCodeZip` imported `node-fetch` (an undeclared
5
+ * dependency) and the `form-data` package: both broke `badgr run . --cmd …`
6
+ * with "Cannot find package 'form-data'". The upload now uses Node's built-in
7
+ * FormData / Blob / fetch (Node >=18), so the CLI needs no extra HTTP deps.
8
+ */
9
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
10
+ import fs from 'fs';
11
+ import os from 'os';
12
+ import path from 'path';
13
+ import { _uploadCodeZip } from '../src/commands/run.js';
14
+
15
+ const chalk = { dim: (s) => s, bold: (s) => s, red: (s) => s };
16
+
17
+ let tmpDir;
18
+
19
+ beforeEach(() => {
20
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'badgr-upload-test-'));
21
+ fs.writeFileSync(path.join(tmpDir, 'hello.py'), "print('hi')\n");
22
+ vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
23
+ });
24
+
25
+ afterEach(() => {
26
+ vi.restoreAllMocks();
27
+ fs.rmSync(tmpDir, { recursive: true, force: true });
28
+ });
29
+
30
+ describe('_uploadCodeZip (local-project flow)', () => {
31
+ it('uses built-in fetch + FormData and returns the backend code_uri', async () => {
32
+ // Built-ins must exist in the supported Node runtime — if these are
33
+ // undefined the upload would fall back to the missing node-fetch/form-data.
34
+ expect(typeof fetch).toBe('function');
35
+ expect(typeof FormData).toBe('function');
36
+ expect(typeof Blob).toBe('function');
37
+
38
+ let captured = null;
39
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, opts) => {
40
+ captured = { url, opts };
41
+ return { ok: true, json: async () => ({ code_uri: 'https://aibadgr.com/v1/uploads/up_x/download?token=t' }) };
42
+ });
43
+
44
+ const config = { apiKey: 'sk-test', baseUrl: 'https://aibadgr.com/v1' };
45
+ const codeUri = await _uploadCodeZip(config, tmpDir, chalk);
46
+
47
+ expect(codeUri).toBe('https://aibadgr.com/v1/uploads/up_x/download?token=t');
48
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
49
+ expect(captured.url).toBe('https://aibadgr.com/v1/uploads');
50
+ expect(captured.opts.method).toBe('POST');
51
+ expect(captured.opts.body).toBeInstanceOf(FormData);
52
+ expect(captured.opts.body.get('file')).toBeInstanceOf(Blob);
53
+ expect(captured.opts.headers.Authorization).toBe('Bearer sk-test');
54
+ // fetch derives the multipart Content-Type/boundary from the FormData body —
55
+ // the CLI must NOT set it manually (that was the form-data getHeaders() path).
56
+ expect(captured.opts.headers['Content-Type']).toBeUndefined();
57
+ });
58
+
59
+ it('throws a clear error and still deletes the temp zip when upload fails', async () => {
60
+ const listZips = () =>
61
+ new Set(fs.readdirSync(os.tmpdir()).filter(f => f.startsWith('badgr-upload-') && f.endsWith('.zip')));
62
+ const before = listZips();
63
+
64
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue({
65
+ ok: false,
66
+ status: 500,
67
+ statusText: 'Internal Server Error',
68
+ text: async () => 'boom',
69
+ });
70
+
71
+ const config = { apiKey: 'sk-test', baseUrl: 'https://aibadgr.com/v1' };
72
+ await expect(_uploadCodeZip(config, tmpDir, chalk)).rejects.toThrow(/Upload failed: 500/);
73
+
74
+ // The temp zip created by THIS call must be cleaned up even on failure.
75
+ const after = listZips();
76
+ const newLeftovers = [...after].filter(f => !before.has(f));
77
+ expect(newLeftovers).toEqual([]);
78
+ });
79
+ });