badgr-cli 1.0.39 → 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
@@ -46,6 +50,14 @@ badgr down <deployment-id>
46
50
  | `badgr capacity` | Check available GPU capacity right now |
47
51
  | `badgr billing` | Show balance and add funds |
48
52
  | `badgr test` | Run an end-to-end test (provision → run → teardown) |
53
+ | `badgr workload list` | List saved workloads with run stats |
54
+ | `badgr workload run <name>` | Rerun a saved workload by name or ID |
55
+ | `badgr workload info <name>` | Show stats, route history, and recent jobs for a workload |
56
+ | `badgr workload delete <name>` | Delete a saved workload |
57
+ | `badgr workspace list` | List workspace trackers (job history + cost per context) |
58
+ | `badgr workspace create <name>` | Create a workspace tracker, optionally linked to a storage path |
59
+ | `badgr workspace info <name>` | Show jobs, cost, and files for a workspace |
60
+ | `badgr workspace delete <name>` | Archive a workspace tracker |
49
61
 
50
62
  `badgr serve` — for anything that needs a persistent endpoint: LLM serving, embeddings, image generation APIs, transcription APIs.
51
63
 
@@ -84,23 +96,36 @@ badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S --region EU
84
96
 
85
97
  ## `badgr run` options
86
98
 
99
+ Three source patterns:
100
+
87
101
  ```bash
88
- 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
89
110
  ```
90
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
+
91
114
  | Flag | Default | Description |
92
115
  |------|---------|-------------|
116
+ | `--cmd <command>` | — | Command to run inside the uploaded project or cloned repo (required for folder/GitHub flows) |
93
117
  | `--gpu <type>` | auto | GPU type override — Badgr Auto selects if omitted |
94
118
  | `--min-vram <GB>` | — | Minimum VRAM in GB — optional constraint for Auto routing |
95
- | `--image <img>` | python:3.11-slim | Docker image |
119
+ | `--image <img>` | | Custom Docker image — bypasses the runner. Mutually exclusive with workspace auto-detection. |
96
120
  | `--env KEY=VALUE` | — | Environment variable (repeatable) |
97
121
  | `--tier 1\|2` | 1 | `1` = reliable execution (default); `2` = lower-cost burst capacity |
98
122
  | `--count <n>` | 1 | Number of GPUs |
99
123
  | `--region US\|EU\|AU` | — | Optional region preference. If omitted, Badgr chooses best available capacity. |
100
124
  | `--max-price <$/hr>` | — | Hard spend cap per GPU-hour |
101
- | `--max-runtime <min>` | — | Auto-stop after N minutes (recommended) |
102
- | `--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) |
103
127
  | `--detach` | — | Launch and return immediately, don't stream logs |
128
+ | `--save <name>` | — | Save this job as a named workload after it completes |
104
129
 
105
130
  ---
106
131
 
@@ -199,6 +224,59 @@ Accepts a public URL, S3/GCS URI, or a local text file under 10 MB. Outputs JSON
199
224
 
200
225
  ---
201
226
 
227
+ ## Workloads
228
+
229
+ A workload is a saved job configuration. Once saved, you can rerun it by name instead of retyping all the flags. Badgr tracks success rate, average cost, average runtime, and the last known-good route so reruns are faster and cheaper over time.
230
+
231
+ **Save a workload** by adding `--save <name>` to any `badgr run` call:
232
+
233
+ ```bash
234
+ badgr run . --cmd "python train.py" --gpu A100 --env HF_TOKEN=$HF_TOKEN --max-cost 10 --save my-training-job
235
+ ```
236
+
237
+ **Rerun a saved workload:**
238
+
239
+ ```bash
240
+ badgr workload run my-training-job
241
+ badgr workload run my-training-job --max-cost 5 # override spend cap
242
+ badgr workload run my-training-job --set HF_TOKEN=newval # override an env var
243
+ ```
244
+
245
+ **Inspect and manage workloads:**
246
+
247
+ ```bash
248
+ badgr workload list # list all saved workloads with stats
249
+ badgr workload info my-training-job # stats, route history, recent jobs
250
+ badgr workload delete my-training-job
251
+ ```
252
+
253
+ | `badgr workload run` flag | Description |
254
+ |---|---|
255
+ | `--max-cost <$>` | Override the saved spend cap for this run |
256
+ | `--max-runtime <min>` | Override the saved runtime limit for this run |
257
+ | `--set KEY=VALUE` | Override a saved config value for this run (repeatable) |
258
+
259
+ ---
260
+
261
+ ## Workspaces
262
+
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.
264
+
265
+ ```bash
266
+ badgr workspace create my-project --storage s3://my-bucket/runs --desc "nightly evals"
267
+ badgr run . --cmd "python eval.py" --workspace my-project --max-cost 5
268
+ badgr workspace info my-project # jobs, total cost, files
269
+ badgr workspace list
270
+ badgr workspace delete my-project
271
+ ```
272
+
273
+ | `badgr workspace create` flag | Description |
274
+ |---|---|
275
+ | `--storage <path>` | S3/GCS path to associate with this workspace |
276
+ | `--desc <text>` | Optional description |
277
+
278
+ ---
279
+
202
280
  ## Routing
203
281
 
204
282
  Badgr Auto selects the best eligible route based on GPU type, VRAM, availability, region, workload requirements, and reliability. Advanced users can optionally choose an execution tier or hardware constraint.
@@ -235,11 +313,13 @@ Each receipt includes: receipt ID, GPU type, provisioning latency, price/hr, ret
235
313
  `badgr serve` provisions a vLLM endpoint that is fully OpenAI-compatible:
236
314
 
237
315
  ```python
316
+ import os
238
317
  from openai import OpenAI
239
318
 
319
+ # Export BADGR_ENDPOINT from the URL printed by `badgr serve`
240
320
  client = OpenAI(
241
- api_key="your-badgr-api-key",
242
- 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"],
243
323
  )
244
324
  resp = client.chat.completions.create(
245
325
  model="meta-llama/Llama-3.1-8B-Instruct",
@@ -251,7 +331,7 @@ resp = client.chat.completions.create(
251
331
  import OpenAI from "openai";
252
332
  const client = new OpenAI({
253
333
  apiKey: process.env.BADGR_API_KEY,
254
- baseURL: "https://dep-a1b2c3.aibadgr.com/v1",
334
+ baseURL: process.env.BADGR_ENDPOINT, // URL printed by `badgr serve`
255
335
  });
256
336
  ```
257
337
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "badgr-cli",
3
- "version": "1.0.39",
4
- "description": "DEPRECATED: Use badgr-agent CLI instead. Badgr, run or serve GPU workloads from one command",
3
+ "version": "1.0.41",
4
+ "description": "Badgr run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "badgr": "src/badgr.js"
@@ -12,7 +12,8 @@
12
12
  "test:watch": "vitest"
13
13
  },
14
14
  "dependencies": {
15
- "badgr-agent": "1.0.0"
15
+ "@inquirer/prompts": "^8.5.2",
16
+ "chalk": "^5.3.0"
16
17
  },
17
18
  "devDependencies": {
18
19
  "vitest": "^4.1.8"
@@ -26,7 +27,12 @@
26
27
  "ai",
27
28
  "compute",
28
29
  "gateway",
29
- "openai"
30
+ "openai",
31
+ "workload",
32
+ "workspace",
33
+ "llm",
34
+ "inference",
35
+ "fine-tuning"
30
36
  ],
31
37
  "license": "Apache-2.0"
32
38
  }
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
+ });