badgr-cli 1.0.46 → 1.0.47

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.
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import os from 'os';
4
4
  import { createWriteStream } from 'fs';
5
5
  import { requireApiKey } from '../config.js';
6
- import { callApi, terminateDeployment } from '../api.js';
6
+ import { callApi, terminateDeployment, uploadBlob } from '../api.js';
7
7
  import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
8
8
  import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
9
9
  import { formatCliError } from '../errors.js';
@@ -428,32 +428,16 @@ export async function _uploadCodeZip(config, dirPath, chalk) {
428
428
  process.stdout.write(chalk.dim(` ${sizeMb} MB\n`));
429
429
 
430
430
  process.stdout.write(chalk.dim(' Uploading project...'));
431
- // POST zip directly to the Badgr backend — no S3, no presigned URLs.
432
- // Built-in FormData/Blob/fetch (Node >=18) keep the CLI dependency-free for
433
- // multipart uploads; fetch sets the multipart boundary header automatically.
434
431
  const fileData = fs.readFileSync(tmpFile);
435
- const form = new FormData();
436
- form.append('file', new Blob([fileData], { type: 'application/zip' }), 'project.zip');
437
-
438
- const baseUrl = config.baseUrl.replace(/\/v1\/?$/, '');
439
432
  let uploadResp;
440
433
  try {
441
- uploadResp = await fetch(`${baseUrl}/v1/uploads`, {
442
- method: 'POST',
443
- body: form,
444
- headers: { 'Authorization': `Bearer ${config.apiKey}` },
445
- });
434
+ uploadResp = await uploadBlob(config, { data: fileData, filename: 'project.zip', contentType: 'application/zip' });
446
435
  } finally {
447
436
  fs.unlinkSync(tmpFile);
448
437
  }
449
438
 
450
- if (!uploadResp.ok) {
451
- const text = await uploadResp.text().catch(() => '');
452
- throw new Error(`Upload failed: ${uploadResp.status} ${uploadResp.statusText}${text ? ` — ${text}` : ''}`);
453
- }
454
- const { code_uri: codeUri } = await uploadResp.json();
455
439
  process.stdout.write(chalk.dim(' done\n'));
456
- return codeUri;
440
+ return uploadResp.code_uri;
457
441
  }
458
442
 
459
443
  export async function runCommand(config, args, chalk) {
@@ -11,6 +11,7 @@ import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
11
11
  import { normalizeTier, callWithFallback } from '../fallback.js';
12
12
  import { monitorBatchJob, fmtRuntime } from '../batch.js';
13
13
  import { pollJobUntilTerminal, renderJobClosingBlock } from '../progress.js';
14
+ import { uploadBlob } from '../api.js';
14
15
 
15
16
  const MAX_CONFIG_B = 512 * 1024; // 512 KB config limit
16
17
 
@@ -116,6 +117,7 @@ export function parseTrainLoraArgs(args) {
116
117
  if (a === '--max-runtime') { flags.maxRuntime = parseFloat(args[++i]); i++; continue; }
117
118
  if (a === '--tier') { flags.tier = args[++i]; i++; continue; }
118
119
  if (a === '--gpu-type') { flags.gpuType = args[++i]; i++; continue; }
120
+ if (a === '--resume') { flags.resume = args[++i]; i++; continue; }
119
121
  if (a === '--dry-run') { flags.dryRun = true; i++; continue; }
120
122
  i++;
121
123
  }
@@ -138,7 +140,8 @@ export async function trainLoraCommand(config, args, chalk) {
138
140
  console.error(chalk.dim(' Dataset sources:'));
139
141
  console.error(chalk.dim(' --dataset ./train.jsonl local file (uploaded first)'));
140
142
  console.error(chalk.dim(' --dataset https://... direct URL'));
141
- console.error(chalk.dim(' --file-id up_abc123 Badgr upload ID\n'));
143
+ console.error(chalk.dim(' --file-id up_abc123 Badgr upload ID'));
144
+ console.error(chalk.dim(' --resume <checkpoint-url> continue training from a prior job\'s checkpoint\n'));
142
145
  process.exitCode = 1;
143
146
  return;
144
147
  }
@@ -164,6 +167,7 @@ export async function trainLoraCommand(config, args, chalk) {
164
167
  }
165
168
  if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost}`);
166
169
  console.log(` ${chalk.bold('Max runtime:')} ${flags.maxRuntime ?? 240}min`);
170
+ if (flags.resume) console.log(` ${chalk.bold('Resume from:')} ${flags.resume}`);
167
171
  console.log(chalk.dim('\n Remove --dry-run to submit (local file datasets are uploaded first).\n'));
168
172
  return;
169
173
  }
@@ -177,36 +181,58 @@ export async function trainLoraCommand(config, args, chalk) {
177
181
  } else if (flags.dataset && (flags.dataset.startsWith('http://') || flags.dataset.startsWith('https://') || flags.dataset.startsWith('s3://'))) {
178
182
  input.dataset_url = flags.dataset;
179
183
  } else if (flags.dataset) {
180
- // Local file — upload first
181
- const { createReadStream, statSync } = await import('fs');
184
+ // Local file — upload first (skipping re-upload if content is unchanged
185
+ // from a prior run, per the local upload cache).
182
186
  if (!existsSync(flags.dataset)) {
183
187
  console.error(chalk.red(`\n ✗ Dataset file not found: ${flags.dataset}\n`));
184
188
  process.exitCode = 1;
185
189
  return;
186
190
  }
187
- console.log(chalk.dim(`\n Uploading dataset ${flags.dataset}…`));
191
+ const { createHash } = await import('crypto');
188
192
  const { readFileSync: readDs } = await import('fs');
193
+ const { getCachedUploadId, setCachedUploadId } = await import('../store.js');
189
194
  const fileData = readDs(flags.dataset);
190
- const form = new FormData();
191
- form.append('file', new Blob([fileData]), flags.dataset.split('/').pop() || 'dataset.jsonl');
192
- const baseUrl = config.baseUrl.replace(/\/v1\/?$/, '');
193
- const uploadRes = await fetch(`${baseUrl}/v1/uploads`, {
194
- method: 'POST',
195
- body: form,
196
- headers: { Authorization: `Bearer ${config.apiKey}` },
197
- });
198
- if (!uploadRes.ok) {
199
- const text = await uploadRes.text().catch(() => '');
200
- console.error(chalk.red(`\n ✗ Dataset upload failed: ${uploadRes.status}${text ? ` — ${text}` : ''}\n`));
201
- process.exitCode = 1;
202
- return;
195
+
196
+ // .jsonl datasets are the common case — catch a malformed file before
197
+ // spending an upload + a GPU on it rather than failing mid-training.
198
+ if (flags.dataset.endsWith('.jsonl')) {
199
+ const lines = fileData.toString('utf8').split('\n').filter(l => l.trim());
200
+ const badLine = lines.findIndex(l => { try { JSON.parse(l); return false; } catch { return true; } });
201
+ if (badLine !== -1) {
202
+ console.error(chalk.red(`\n ✗ Dataset is not valid JSONL — line ${badLine + 1} is not valid JSON.\n`));
203
+ process.exitCode = 1;
204
+ return;
205
+ }
206
+ if (lines.length === 0) {
207
+ console.error(chalk.red(`\n ✗ Dataset file ${flags.dataset} is empty.\n`));
208
+ process.exitCode = 1;
209
+ return;
210
+ }
211
+ }
212
+
213
+ const contentHash = createHash('sha256').update(fileData).digest('hex');
214
+ const cachedUploadId = getCachedUploadId(contentHash);
215
+ if (cachedUploadId) {
216
+ input.dataset_file_id = cachedUploadId;
217
+ console.log(chalk.dim(`\n Dataset unchanged since last run — reusing upload ${cachedUploadId} (no re-upload)`));
218
+ } else {
219
+ console.log(chalk.dim(`\n Uploading dataset ${flags.dataset}…`));
220
+ let uploadResp;
221
+ try {
222
+ uploadResp = await uploadBlob(config, { data: fileData, filename: flags.dataset.split('/').pop() || 'dataset.jsonl' });
223
+ } catch (err) {
224
+ console.error(chalk.red(`\n ✗ Dataset upload failed: ${err.message}\n`));
225
+ process.exitCode = 1;
226
+ return;
227
+ }
228
+ input.dataset_file_id = uploadResp.upload_id;
229
+ setCachedUploadId(contentHash, uploadResp.upload_id, { file: flags.dataset });
230
+ console.log(chalk.dim(` Uploaded: ${uploadResp.upload_id}`));
203
231
  }
204
- const uploadResp = await uploadRes.json();
205
- input.dataset_file_id = uploadResp.upload_id;
206
- console.log(chalk.dim(` Uploaded: ${uploadResp.upload_id}`));
207
232
  }
208
233
 
209
234
  if (flags.gpuType) input.gpu_type = flags.gpuType;
235
+ if (flags.resume) input.resume_from_checkpoint_url = flags.resume;
210
236
 
211
237
  const rcptId = generateReceiptId();
212
238
  const maxRuntime = flags.maxRuntime ?? 240;
@@ -215,7 +241,9 @@ export async function trainLoraCommand(config, args, chalk) {
215
241
  console.log(` ${chalk.bold('Base model:')} ${flags.baseModel}`);
216
242
  console.log(` ${chalk.bold('Preset:')} ${input.config_preset}`);
217
243
  console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost}`);
218
- console.log(` ${chalk.bold('Max runtime:')} ${maxRuntime} min\n`);
244
+ console.log(` ${chalk.bold('Max runtime:')} ${maxRuntime} min`);
245
+ if (flags.resume) console.log(` ${chalk.bold('Resuming from:')} ${flags.resume}`);
246
+ console.log();
219
247
 
220
248
  let job;
221
249
  try {
package/src/store.js CHANGED
@@ -106,3 +106,30 @@ export function findReceipt(idOrJobId, storeFile = STORE_FILE) {
106
106
  const { receipts } = loadStore(storeFile);
107
107
  return receipts.find(r => r.receiptId === idOrJobId || r.job_id === idOrJobId) ?? null;
108
108
  }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Upload cache — content-hash → upload_id, so re-running the same dataset
112
+ // (e.g. `badgr train lora` reruns, iterating on a config) skips re-uploading
113
+ // unchanged files. Persisted to ~/.badgr/upload-cache.json.
114
+ // ---------------------------------------------------------------------------
115
+ export const UPLOAD_CACHE_FILE = join(CONFIG_DIR, 'upload-cache.json');
116
+
117
+ export function loadUploadCache(cacheFile = UPLOAD_CACHE_FILE) {
118
+ if (!existsSync(cacheFile)) return {};
119
+ try {
120
+ return JSON.parse(readFileSync(cacheFile, 'utf8'));
121
+ } catch {
122
+ return {};
123
+ }
124
+ }
125
+
126
+ export function getCachedUploadId(contentHash, cacheFile = UPLOAD_CACHE_FILE) {
127
+ return loadUploadCache(cacheFile)[contentHash]?.uploadId ?? null;
128
+ }
129
+
130
+ export function setCachedUploadId(contentHash, uploadId, meta = {}, cacheFile = UPLOAD_CACHE_FILE) {
131
+ const cache = loadUploadCache(cacheFile);
132
+ cache[contentHash] = { uploadId, cachedAt: new Date().toISOString(), ...meta };
133
+ mkdirSync(dirname(cacheFile), { recursive: true });
134
+ writeFileSync(cacheFile, JSON.stringify(cache, null, 2));
135
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * workload.yml parsing/validation for `badgr batch`.
3
+ *
4
+ * Deliberately generic — not robotics-specific. Physical-AI is just one
5
+ * workload that happens to use this schema, not a special case of it.
6
+ *
7
+ * Schema:
8
+ * name: <string> required
9
+ * image: <string> required
10
+ * command: [<string>, ...] required
11
+ * inputs: ["<local>:<container abs path>", ...] optional
12
+ * outputs: ["<container abs path>", ...] optional
13
+ * env: {KEY: value} optional
14
+ * max_cost: <number> required
15
+ * max_runtime_minutes: <number> required
16
+ * success_metric: optional
17
+ * file: <container abs path>
18
+ * key: <string>
19
+ * higher_is_better: <bool>
20
+ */
21
+ import { readFileSync } from 'fs';
22
+ import { dirname, resolve } from 'path';
23
+ import yaml from 'js-yaml';
24
+
25
+ export class WorkloadSpecError extends Error {}
26
+
27
+ function isNonEmptyString(v) {
28
+ return typeof v === 'string' && v.length > 0;
29
+ }
30
+
31
+ /**
32
+ * Parse a single "local:containerPath" inputs entry. containerPath must be
33
+ * an absolute path (workload.yml is container-path-oriented for inputs);
34
+ * splitting on the *last* colon so Windows-style local paths with a drive
35
+ * letter don't break parsing.
36
+ */
37
+ export function parseInputEntry(entry) {
38
+ const idx = entry.lastIndexOf(':');
39
+ if (idx <= 0 || idx === entry.length - 1) {
40
+ throw new WorkloadSpecError(`inputs entry must be "<local path>:<container path>": ${entry}`);
41
+ }
42
+ const localPath = entry.slice(0, idx);
43
+ const containerPath = entry.slice(idx + 1);
44
+ if (!containerPath.startsWith('/')) {
45
+ throw new WorkloadSpecError(`inputs entry's container path must be absolute: ${entry}`);
46
+ }
47
+ return { localPath, containerPath };
48
+ }
49
+
50
+ export function validateWorkloadSpec(raw) {
51
+ const errors = [];
52
+ if (!isNonEmptyString(raw?.name)) errors.push('name is required');
53
+ if (!isNonEmptyString(raw?.image)) errors.push('image is required');
54
+ if (!Array.isArray(raw?.command) || raw.command.length === 0) errors.push('command is required and must be a non-empty list');
55
+ if (raw?.max_cost == null || typeof raw.max_cost !== 'number' || raw.max_cost <= 0) errors.push('max_cost is required and must be a positive number');
56
+ if (raw?.max_runtime_minutes == null || typeof raw.max_runtime_minutes !== 'number' || raw.max_runtime_minutes <= 0) errors.push('max_runtime_minutes is required and must be a positive number');
57
+
58
+ if (raw?.inputs !== undefined) {
59
+ if (!Array.isArray(raw.inputs)) {
60
+ errors.push('inputs must be a list');
61
+ } else {
62
+ for (const entry of raw.inputs) {
63
+ try { parseInputEntry(entry); } catch (err) { errors.push(err.message); }
64
+ }
65
+ }
66
+ }
67
+ if (raw?.outputs !== undefined) {
68
+ if (!Array.isArray(raw.outputs) || raw.outputs.some(p => typeof p !== 'string' || !p.startsWith('/'))) {
69
+ errors.push('outputs must be a list of absolute container paths');
70
+ }
71
+ }
72
+ if (raw?.success_metric !== undefined) {
73
+ const sm = raw.success_metric;
74
+ if (!sm || !isNonEmptyString(sm.file) || !sm.file.startsWith('/')) errors.push('success_metric.file must be an absolute container path');
75
+ if (!isNonEmptyString(sm?.key)) errors.push('success_metric.key is required when success_metric is set');
76
+ if (sm?.higher_is_better !== undefined && typeof sm.higher_is_better !== 'boolean') errors.push('success_metric.higher_is_better must be a boolean');
77
+ }
78
+ return errors;
79
+ }
80
+
81
+ /**
82
+ * Load and validate a workload.yml file. Returns a normalized spec object.
83
+ * Throws WorkloadSpecError with all validation problems on failure.
84
+ */
85
+ export function parseWorkloadYaml(path) {
86
+ let raw;
87
+ try {
88
+ raw = readFileSync(path, 'utf8');
89
+ } catch (err) {
90
+ throw new WorkloadSpecError(`Could not read ${path}: ${err.message}`);
91
+ }
92
+ let doc;
93
+ try {
94
+ doc = yaml.load(raw);
95
+ } catch (err) {
96
+ throw new WorkloadSpecError(`Invalid YAML in ${path}: ${err.message}`);
97
+ }
98
+ const errors = validateWorkloadSpec(doc);
99
+ if (errors.length > 0) {
100
+ throw new WorkloadSpecError(`Invalid workload.yml:\n${errors.map(e => ` - ${e}`).join('\n')}`);
101
+ }
102
+
103
+ const baseDir = dirname(resolve(path));
104
+ const inputs = (doc.inputs ?? []).map(entry => {
105
+ const { localPath, containerPath } = parseInputEntry(entry);
106
+ return { localPath: resolve(baseDir, localPath), containerPath };
107
+ });
108
+
109
+ return {
110
+ name: doc.name,
111
+ image: doc.image,
112
+ command: doc.command,
113
+ inputs,
114
+ outputs: doc.outputs ?? [],
115
+ env: doc.env ?? {},
116
+ maxCost: doc.max_cost,
117
+ maxRuntimeMinutes: doc.max_runtime_minutes,
118
+ successMetric: doc.success_metric
119
+ ? {
120
+ file: doc.success_metric.file,
121
+ key: doc.success_metric.key,
122
+ higherIsBetter: doc.success_metric.higher_is_better ?? true,
123
+ }
124
+ : null,
125
+ };
126
+ }
package/tests/api.test.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { callApi, listModels, chatCompletion, submitJob, getJobStatus, listReceipts, getReceipt, runJob, serveModel } from '../src/api.js';
2
+ import { callApi, listModels, chatCompletion, submitJob, getJobStatus, listReceipts, getReceipt, runJob, serveModel, uploadBlob } from '../src/api.js';
3
3
 
4
4
  const mockConfig = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1', defaultModel: 'llama-3' };
5
5
 
@@ -138,3 +138,31 @@ describe('serveModel', () => {
138
138
  expect(result.deployment_id).toBe('dep-xyz');
139
139
  });
140
140
  });
141
+
142
+ describe('uploadBlob', () => {
143
+ it('POSTs multipart form data to /v1/uploads with Authorization, no manual Content-Type', async () => {
144
+ mockFetch({ upload_id: 'up_1', code_uri: 'https://api.test/v1/uploads/up_1/download?token=t' });
145
+ const result = await uploadBlob(mockConfig, { data: Buffer.from('hello'), filename: 'a.txt' });
146
+ const [url, init] = global.fetch.mock.calls[0];
147
+ expect(url).toBe('https://api.test/v1/uploads');
148
+ expect(init.method).toBe('POST');
149
+ expect(init.body).toBeInstanceOf(FormData);
150
+ expect(init.headers.Authorization).toBe('Bearer sk-test');
151
+ expect(init.headers['Content-Type']).toBeUndefined();
152
+ expect(result.upload_id).toBe('up_1');
153
+ expect(result.code_uri).toContain('up_1');
154
+ });
155
+
156
+ it('sets the Blob content type when contentType is given', async () => {
157
+ mockFetch({ upload_id: 'up_2', code_uri: 'https://x/up_2' });
158
+ await uploadBlob(mockConfig, { data: Buffer.from('x'), filename: 'a.tar.gz', contentType: 'application/gzip' });
159
+ const [, init] = global.fetch.mock.calls[0];
160
+ expect(init.body.get('file').type).toBe('application/gzip');
161
+ });
162
+
163
+ it('throws a clear error on a non-ok response', async () => {
164
+ mockFetch('boom', false, 500);
165
+ await expect(uploadBlob(mockConfig, { data: Buffer.from('x'), filename: 'a.txt' }))
166
+ .rejects.toThrow(/Upload failed: 500/);
167
+ });
168
+ });