badgr-cli 1.0.46 → 1.0.48
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/package.json +4 -2
- package/src/api.js +39 -0
- package/src/badgr.js +15 -0
- package/src/commands/batch.js +620 -0
- package/src/commands/rerun.js +75 -0
- package/src/commands/run.js +3 -19
- package/src/commands/train.js +49 -21
- package/src/store.js +27 -0
- package/src/workloadSpec.js +126 -0
- package/tests/api.test.js +29 -1
- package/tests/batch.test.js +329 -0
- package/tests/rerun.test.js +94 -0
- package/tests/train-lora-dataset.test.js +176 -0
- package/tests/workload-spec.test.js +180 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* badgr batch — /run body shape, missing-artifact-path detection, and
|
|
3
|
+
* `badgr batch compare` regression logic against literal metrics.json
|
|
4
|
+
* fixtures (satisfies test scenario 5 without needing a live run).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
|
|
8
|
+
import { tmpdir } from 'os';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
|
|
11
|
+
vi.mock('../src/store.js', () => ({
|
|
12
|
+
addReceipt: vi.fn(),
|
|
13
|
+
updateReceipt: vi.fn(),
|
|
14
|
+
generateReceiptId: vi.fn(() => 'rcpt-batch-001'),
|
|
15
|
+
loadStore: vi.fn(() => ({ receipts: [], deployments: [] })),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock('../src/api.js', () => ({
|
|
19
|
+
callApi: vi.fn(),
|
|
20
|
+
uploadBlob: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock('../src/fallback.js', async (importOriginal) => {
|
|
24
|
+
const actual = await importOriginal();
|
|
25
|
+
return { ...actual, callWithFallback: vi.fn() };
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
vi.mock('../src/batch.js', () => ({
|
|
29
|
+
monitorBatchJob: vi.fn(() => Promise.resolve({ status: 'succeeded', exitCode: 0, runtimeMs: 42000, reason: 'complete' })),
|
|
30
|
+
fmtRuntime: (ms) => `${Math.round(ms / 1000)}s`,
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
vi.mock('archiver', () => ({
|
|
34
|
+
default: () => {
|
|
35
|
+
let out;
|
|
36
|
+
return {
|
|
37
|
+
on: () => {},
|
|
38
|
+
pipe: (o) => { out = o; },
|
|
39
|
+
directory: () => {},
|
|
40
|
+
file: () => {},
|
|
41
|
+
finalize: () => { out.end(); },
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
import {
|
|
47
|
+
batchCommand,
|
|
48
|
+
displayFailureReason,
|
|
49
|
+
missingOutputPaths,
|
|
50
|
+
readMetricFile,
|
|
51
|
+
} from '../src/commands/batch.js';
|
|
52
|
+
import * as store from '../src/store.js';
|
|
53
|
+
import * as api from '../src/api.js';
|
|
54
|
+
import * as fallback from '../src/fallback.js';
|
|
55
|
+
|
|
56
|
+
const chalk = {
|
|
57
|
+
bold: s => s, dim: s => s, red: s => s, yellow: s => s, green: s => s, cyan: s => s,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
|
|
61
|
+
|
|
62
|
+
let dir;
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
dir = mkdtempSync(join(tmpdir(), 'badgr-batch-cmd-test-'));
|
|
65
|
+
process.exitCode = undefined;
|
|
66
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
67
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
68
|
+
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
69
|
+
vi.clearAllMocks();
|
|
70
|
+
store.loadStore.mockReturnValue({ receipts: [], deployments: [] });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
rmSync(dir, { recursive: true, force: true });
|
|
75
|
+
vi.restoreAllMocks();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// displayFailureReason
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
describe('displayFailureReason', () => {
|
|
83
|
+
it('maps max_runtime to runtime_limit_exceeded', () => {
|
|
84
|
+
expect(displayFailureReason('max_runtime')).toBe('runtime_limit_exceeded');
|
|
85
|
+
});
|
|
86
|
+
it('maps max_cost to cost_cap_exceeded', () => {
|
|
87
|
+
expect(displayFailureReason('max_cost')).toBe('cost_cap_exceeded');
|
|
88
|
+
});
|
|
89
|
+
it('passes through unknown reasons unchanged', () => {
|
|
90
|
+
expect(displayFailureReason('something_else')).toBe('something_else');
|
|
91
|
+
});
|
|
92
|
+
it('returns null for no reason', () => {
|
|
93
|
+
expect(displayFailureReason(null)).toBeNull();
|
|
94
|
+
expect(displayFailureReason(undefined)).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// missingOutputPaths / readMetricFile
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
describe('missingOutputPaths', () => {
|
|
103
|
+
it('reports declared paths that do not exist under the extraction dir', () => {
|
|
104
|
+
mkdirSync(join(dir, 'outputs'), { recursive: true });
|
|
105
|
+
writeFileSync(join(dir, 'outputs', 'metrics.json'), '{}');
|
|
106
|
+
const missing = missingOutputPaths(dir, ['/outputs/metrics.json', '/outputs/videos', '/outputs/logs']);
|
|
107
|
+
expect(missing).toEqual(['/outputs/videos', '/outputs/logs']);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('reports nothing missing when all declared paths exist', () => {
|
|
111
|
+
mkdirSync(join(dir, 'outputs', 'videos'), { recursive: true });
|
|
112
|
+
writeFileSync(join(dir, 'outputs', 'metrics.json'), '{}');
|
|
113
|
+
const missing = missingOutputPaths(dir, ['/outputs/metrics.json', '/outputs/videos']);
|
|
114
|
+
expect(missing).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('readMetricFile', () => {
|
|
119
|
+
it('reads and parses a metrics JSON file', () => {
|
|
120
|
+
mkdirSync(join(dir, 'outputs'), { recursive: true });
|
|
121
|
+
writeFileSync(join(dir, 'outputs', 'metrics.json'), JSON.stringify({ pass_rate: 0.9 }));
|
|
122
|
+
expect(readMetricFile(dir, '/outputs/metrics.json')).toEqual({ pass_rate: 0.9 });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('returns null when the file does not exist', () => {
|
|
126
|
+
expect(readMetricFile(dir, '/outputs/metrics.json')).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns null on malformed JSON', () => {
|
|
130
|
+
mkdirSync(join(dir, 'outputs'), { recursive: true });
|
|
131
|
+
writeFileSync(join(dir, 'outputs', 'metrics.json'), 'not json');
|
|
132
|
+
expect(readMetricFile(dir, '/outputs/metrics.json')).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// badgr batch run — /run body shape (image/command/env/caps)
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
describe('badgr batch run — /run request shape', () => {
|
|
141
|
+
it('builds the /run body from workload.yml with inputs/outputs env wiring', async () => {
|
|
142
|
+
writeFileSync(join(dir, 'policy.py'), 'def act(obs): return 0');
|
|
143
|
+
mkdirSync(join(dir, 'scenarios'));
|
|
144
|
+
writeFileSync(join(dir, 'scenarios', 's0.json'), '{}');
|
|
145
|
+
const yamlPath = join(dir, 'workload.yml');
|
|
146
|
+
writeFileSync(yamlPath, `
|
|
147
|
+
name: toy-robot-eval
|
|
148
|
+
image: badgr/physical-ai-eval-lite:latest
|
|
149
|
+
command: [python, run_eval.py, --policy, /inputs/policy.py, --scenarios, /inputs/scenarios]
|
|
150
|
+
inputs:
|
|
151
|
+
- ./policy.py:/inputs/policy.py
|
|
152
|
+
- ./scenarios:/inputs/scenarios
|
|
153
|
+
outputs:
|
|
154
|
+
- /outputs/metrics.json
|
|
155
|
+
- /outputs/videos
|
|
156
|
+
- /outputs/logs
|
|
157
|
+
max_cost: 20
|
|
158
|
+
max_runtime_minutes: 60
|
|
159
|
+
success_metric:
|
|
160
|
+
file: /outputs/metrics.json
|
|
161
|
+
key: pass_rate
|
|
162
|
+
higher_is_better: true
|
|
163
|
+
`);
|
|
164
|
+
|
|
165
|
+
let capturedBody;
|
|
166
|
+
fallback.callWithFallback.mockImplementation((endpoint, callOpts, buildBody) => {
|
|
167
|
+
capturedBody = buildBody();
|
|
168
|
+
return Promise.resolve({
|
|
169
|
+
deployment_id: 'dep-abc123', receipt_id: 'rcpt-1', gpu_type: 'RTX_4090',
|
|
170
|
+
gpu_count: 1, cost_per_hour: 0.5, provider: 'runpod', tier: '1', status: 'running',
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
api.callApi.mockImplementation((path) => {
|
|
175
|
+
if (path.endsWith('/artifacts')) return Promise.reject(new Error('no artifacts yet'));
|
|
176
|
+
if (path.startsWith('/deployments/')) {
|
|
177
|
+
return Promise.resolve({
|
|
178
|
+
status: 'succeeded', failure_reason: null, teardown_ok: 'ok',
|
|
179
|
+
runtime_seconds: 42, accrued_cost_usd: 0.01, provider: 'runpod',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return Promise.resolve({});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
api.uploadBlob.mockResolvedValue({ code_uri: 'https://cdn.example.com/inputs.tar.gz', upload_id: 'up_1' });
|
|
186
|
+
|
|
187
|
+
await batchCommand(config, ['run', yamlPath], chalk);
|
|
188
|
+
|
|
189
|
+
expect(capturedBody.image).toBe('badgr/physical-ai-eval-lite:latest');
|
|
190
|
+
expect(capturedBody.command).toEqual(['python', 'run_eval.py', '--policy', '/inputs/policy.py', '--scenarios', '/inputs/scenarios']);
|
|
191
|
+
expect(capturedBody.max_cost_usd).toBe(20);
|
|
192
|
+
expect(capturedBody.max_runtime_seconds).toBe(3600);
|
|
193
|
+
expect(capturedBody.custom_image).toBe(true);
|
|
194
|
+
expect(capturedBody.env.BADGR_INPUTS_ARCHIVE_URL).toBe('https://cdn.example.com/inputs.tar.gz');
|
|
195
|
+
expect(JSON.parse(capturedBody.env.BADGR_OUTPUT_PATHS_JSON)).toEqual([
|
|
196
|
+
'/outputs/metrics.json', '/outputs/videos', '/outputs/logs',
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('rejects a workload.yml missing required fields before touching the network', async () => {
|
|
201
|
+
const yamlPath = join(dir, 'bad.yml');
|
|
202
|
+
writeFileSync(yamlPath, 'image: busybox\n');
|
|
203
|
+
await batchCommand(config, ['run', yamlPath], chalk);
|
|
204
|
+
expect(process.exitCode).toBe(1);
|
|
205
|
+
expect(fallback.callWithFallback).not.toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('builds a body with no BADGR_* env vars when inputs/outputs are omitted', async () => {
|
|
209
|
+
const yamlPath = join(dir, 'workload.yml');
|
|
210
|
+
writeFileSync(yamlPath, `
|
|
211
|
+
name: simple-job
|
|
212
|
+
image: busybox
|
|
213
|
+
command: [echo, hi]
|
|
214
|
+
max_cost: 1
|
|
215
|
+
max_runtime_minutes: 5
|
|
216
|
+
`);
|
|
217
|
+
|
|
218
|
+
let capturedBody;
|
|
219
|
+
fallback.callWithFallback.mockImplementation((endpoint, callOpts, buildBody) => {
|
|
220
|
+
capturedBody = buildBody();
|
|
221
|
+
return Promise.resolve({
|
|
222
|
+
deployment_id: 'dep-xyz', receipt_id: 'rcpt-2', gpu_type: 'RTX_4090',
|
|
223
|
+
gpu_count: 1, cost_per_hour: 0.1, provider: 'runpod', tier: '1', status: 'running',
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
api.callApi.mockImplementation((path) => {
|
|
227
|
+
if (path.startsWith('/deployments/')) {
|
|
228
|
+
return Promise.resolve({ status: 'succeeded', teardown_ok: 'ok' });
|
|
229
|
+
}
|
|
230
|
+
return Promise.resolve({});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await batchCommand(config, ['run', yamlPath], chalk);
|
|
234
|
+
|
|
235
|
+
expect(capturedBody.env.BADGR_INPUTS_ARCHIVE_URL).toBeUndefined();
|
|
236
|
+
expect(capturedBody.env.BADGR_OUTPUT_PATHS_JSON).toBeUndefined();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// badgr batch artifacts — clear, actionable failure messages
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
describe('badgr batch artifacts — error clarity', () => {
|
|
245
|
+
it('explains a 404 (no artifact ever uploaded) with likely causes and a next step', async () => {
|
|
246
|
+
const err = new Error('GET /deployments/dep-nofiles/artifacts → HTTP 404: artifact_not_found');
|
|
247
|
+
err.httpStatus = 404;
|
|
248
|
+
api.callApi.mockImplementation((path) => {
|
|
249
|
+
if (path.endsWith('/artifacts')) return Promise.reject(err);
|
|
250
|
+
return Promise.resolve({});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
await batchCommand(config, ['artifacts', 'dep-nofiles'], chalk);
|
|
254
|
+
|
|
255
|
+
expect(process.exitCode).toBe(1);
|
|
256
|
+
const logged = console.error.mock.calls.flat().join('\n');
|
|
257
|
+
expect(logged).toMatch(/No artifact was ever uploaded for run dep-nofiles/);
|
|
258
|
+
expect(logged).toMatch(/badgr batch logs dep-nofiles/);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('explains a 410 (expired, 48h retention) plainly', async () => {
|
|
262
|
+
const err = new Error('GET /deployments/dep-old/artifacts → HTTP 410: artifact_expired');
|
|
263
|
+
err.httpStatus = 410;
|
|
264
|
+
api.callApi.mockImplementation((path) => {
|
|
265
|
+
if (path.endsWith('/artifacts')) return Promise.reject(err);
|
|
266
|
+
return Promise.resolve({});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
await batchCommand(config, ['artifacts', 'dep-old'], chalk);
|
|
270
|
+
|
|
271
|
+
expect(process.exitCode).toBe(1);
|
|
272
|
+
const logged = console.error.mock.calls.flat().join('\n');
|
|
273
|
+
expect(logged).toMatch(/expired \(48h retention\)/);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// badgr batch compare — direct metrics.json files (test scenario 5)
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
describe('badgr batch compare', () => {
|
|
282
|
+
it('reports a regression with delta when run B is worse (higher_is_better)', async () => {
|
|
283
|
+
const a = join(dir, 'a.json');
|
|
284
|
+
const b = join(dir, 'b.json');
|
|
285
|
+
writeFileSync(a, JSON.stringify({ pass_rate: 0.92 }));
|
|
286
|
+
writeFileSync(b, JSON.stringify({ pass_rate: 0.87 }));
|
|
287
|
+
|
|
288
|
+
await batchCommand(config, ['compare', a, b], chalk);
|
|
289
|
+
|
|
290
|
+
expect(process.exitCode).toBe(1);
|
|
291
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
292
|
+
expect(logged).toMatch(/Regression/);
|
|
293
|
+
expect(logged).toContain('0.92');
|
|
294
|
+
expect(logged).toContain('0.87');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('reports an improvement when run B is better', async () => {
|
|
298
|
+
const a = join(dir, 'a.json');
|
|
299
|
+
const b = join(dir, 'b.json');
|
|
300
|
+
writeFileSync(a, JSON.stringify({ pass_rate: 0.80 }));
|
|
301
|
+
writeFileSync(b, JSON.stringify({ pass_rate: 0.95 }));
|
|
302
|
+
|
|
303
|
+
await batchCommand(config, ['compare', a, b], chalk);
|
|
304
|
+
|
|
305
|
+
expect(process.exitCode).toBeUndefined();
|
|
306
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
307
|
+
expect(logged).toMatch(/Improvement/);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('respects --key and --higher-is-better overrides for raw files', async () => {
|
|
311
|
+
const a = join(dir, 'a.json');
|
|
312
|
+
const b = join(dir, 'b.json');
|
|
313
|
+
writeFileSync(a, JSON.stringify({ error_rate: 0.05 }));
|
|
314
|
+
writeFileSync(b, JSON.stringify({ error_rate: 0.10 }));
|
|
315
|
+
|
|
316
|
+
// error_rate: lower is better, so b (higher) is a regression.
|
|
317
|
+
await batchCommand(config, ['compare', a, b, '--key', 'error_rate', '--higher-is-better', 'false'], chalk);
|
|
318
|
+
|
|
319
|
+
expect(process.exitCode).toBe(1);
|
|
320
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
321
|
+
expect(logged).toMatch(/Regression/);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('errors out when a run_id has no known success_metric and no local YAML', async () => {
|
|
325
|
+
store.loadStore.mockReturnValueOnce({ receipts: [], deployments: [] });
|
|
326
|
+
await batchCommand(config, ['compare', 'dep-unknown-a', 'dep-unknown-b'], chalk);
|
|
327
|
+
expect(process.exitCode).toBe(1);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* badgr rerun — replays a past job or endpoint with its exact original spec
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
import { rerunCommand } from '../src/commands/rerun.js';
|
|
6
|
+
|
|
7
|
+
vi.mock('../src/api.js', () => ({
|
|
8
|
+
rerunDeployment: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock('../src/store.js', () => ({
|
|
12
|
+
findDeployment: vi.fn(() => null),
|
|
13
|
+
addDeployment: vi.fn(),
|
|
14
|
+
addReceipt: vi.fn(),
|
|
15
|
+
generateReceiptId: vi.fn(() => 'rcpt-rerun-001'),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock('../src/config.js', () => ({
|
|
19
|
+
requireApiKey: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import * as api from '../src/api.js';
|
|
23
|
+
import * as store from '../src/store.js';
|
|
24
|
+
|
|
25
|
+
const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
|
|
26
|
+
|
|
27
|
+
const chalk = {
|
|
28
|
+
bold: s => s,
|
|
29
|
+
dim: s => s,
|
|
30
|
+
red: s => s,
|
|
31
|
+
yellow: s => s,
|
|
32
|
+
green: s => s,
|
|
33
|
+
cyan: s => s,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
process.exitCode = undefined;
|
|
38
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
39
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
40
|
+
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
41
|
+
vi.resetAllMocks();
|
|
42
|
+
store.findDeployment.mockReturnValue(null);
|
|
43
|
+
store.generateReceiptId.mockReturnValue('rcpt-rerun-001');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.restoreAllMocks();
|
|
48
|
+
process.exitCode = undefined;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('requires a deployment id', async () => {
|
|
52
|
+
await rerunCommand(config, [], chalk);
|
|
53
|
+
expect(api.rerunDeployment).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('calls rerunDeployment and prints the new deployment id, never removing the source', async () => {
|
|
57
|
+
api.rerunDeployment.mockResolvedValue({
|
|
58
|
+
deployment_id: 'dep-new-002',
|
|
59
|
+
workload_type: 'job',
|
|
60
|
+
gpu_type: 'A100',
|
|
61
|
+
gpu_count: 1,
|
|
62
|
+
status: 'provisioning',
|
|
63
|
+
rerun_of: 'dep-old-001',
|
|
64
|
+
cost_per_hour: 1.9,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const lines = [];
|
|
68
|
+
console.log.mockImplementation((...args) => lines.push(args.join(' ')));
|
|
69
|
+
|
|
70
|
+
await rerunCommand(config, ['dep-old-001'], chalk);
|
|
71
|
+
|
|
72
|
+
expect(api.rerunDeployment).toHaveBeenCalledWith(config, 'dep-old-001');
|
|
73
|
+
expect(store.addDeployment).toHaveBeenCalledWith(expect.objectContaining({ id: 'dep-new-002' }));
|
|
74
|
+
expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({ rerunOf: 'dep-old-001' }));
|
|
75
|
+
expect(lines.some(l => l.includes('dep-new-002'))).toBe(true);
|
|
76
|
+
expect(process.exitCode).toBeFalsy();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('resolves a local name/alias to its deployment id before calling the API', async () => {
|
|
80
|
+
store.findDeployment.mockReturnValue({ id: 'dep-resolved-999' });
|
|
81
|
+
api.rerunDeployment.mockResolvedValue({ deployment_id: 'dep-new-003', workload_type: 'job', status: 'provisioning' });
|
|
82
|
+
|
|
83
|
+
await rerunCommand(config, ['my-job-alias'], chalk);
|
|
84
|
+
|
|
85
|
+
expect(api.rerunDeployment).toHaveBeenCalledWith(config, 'dep-resolved-999');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('reports an error and sets exitCode on failure', async () => {
|
|
89
|
+
api.rerunDeployment.mockRejectedValue(new Error('deployment not found'));
|
|
90
|
+
|
|
91
|
+
await rerunCommand(config, ['dep-missing'], chalk);
|
|
92
|
+
|
|
93
|
+
expect(process.exitCode).toBe(1);
|
|
94
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* badgr train lora — dataset validation, upload dedup, and --resume.
|
|
3
|
+
*
|
|
4
|
+
* A malformed local .jsonl dataset must be rejected before it's uploaded or
|
|
5
|
+
* a GPU is provisioned. A dataset whose content hasn't changed since a prior
|
|
6
|
+
* run must not be re-uploaded (upload cache keyed by content hash). --resume
|
|
7
|
+
* must thread resume_from_checkpoint_url into the job input.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
10
|
+
import { trainLoraCommand, parseTrainLoraArgs } from '../src/commands/train.js';
|
|
11
|
+
|
|
12
|
+
vi.mock('../src/api.js', async (importOriginal) => {
|
|
13
|
+
const actual = await importOriginal();
|
|
14
|
+
return { ...actual, callApi: vi.fn(), uploadBlob: actual.uploadBlob };
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
vi.mock('../src/store.js', () => ({
|
|
18
|
+
addReceipt: vi.fn(),
|
|
19
|
+
generateReceiptId: vi.fn(() => 'rcpt-ds-001'),
|
|
20
|
+
getCachedUploadId: vi.fn(() => null),
|
|
21
|
+
setCachedUploadId: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock('fs', async (importOriginal) => {
|
|
25
|
+
const actual = await importOriginal();
|
|
26
|
+
return {
|
|
27
|
+
...actual,
|
|
28
|
+
readFileSync: vi.fn(),
|
|
29
|
+
existsSync: vi.fn(() => true),
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
import * as api from '../src/api.js';
|
|
34
|
+
import * as store from '../src/store.js';
|
|
35
|
+
import * as fs from 'fs';
|
|
36
|
+
|
|
37
|
+
const chalk = {
|
|
38
|
+
bold: s => s, dim: s => s, red: s => s, yellow: s => s, green: s => s, cyan: s => s,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
process.exitCode = undefined;
|
|
45
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
46
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
47
|
+
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
48
|
+
vi.clearAllMocks();
|
|
49
|
+
fs.existsSync.mockReturnValue(true);
|
|
50
|
+
store.getCachedUploadId.mockReturnValue(null);
|
|
51
|
+
// POST /jobs returns the created job; the subsequent GET /jobs/{id} poll
|
|
52
|
+
// (pollJobUntilTerminal, 15s real-world interval) must resolve terminal
|
|
53
|
+
// immediately so these tests don't wait on real timers.
|
|
54
|
+
api.callApi.mockImplementation((path) => {
|
|
55
|
+
if (path === '/jobs') return Promise.resolve({ job_id: 'job-1', status: 'queued' });
|
|
56
|
+
return Promise.resolve({ job_id: 'job-1', status: 'completed', output: {} });
|
|
57
|
+
});
|
|
58
|
+
vi.useFakeTimers();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
vi.useRealTimers();
|
|
63
|
+
vi.restoreAllMocks();
|
|
64
|
+
process.exitCode = undefined;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
async function runTrainLora(args) {
|
|
68
|
+
const promise = trainLoraCommand(config, args, chalk);
|
|
69
|
+
await vi.advanceTimersByTimeAsync(20_000);
|
|
70
|
+
return promise;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('parseTrainLoraArgs --resume', () => {
|
|
74
|
+
it('parses --resume', () => {
|
|
75
|
+
const flags = parseTrainLoraArgs(['--base-model', 'x', '--resume', 'https://cdn/ckpt.tar.gz']);
|
|
76
|
+
expect(flags.resume).toBe('https://cdn/ckpt.tar.gz');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('trainLoraCommand dataset validation', () => {
|
|
81
|
+
it('rejects a .jsonl dataset with an invalid line before uploading', async () => {
|
|
82
|
+
fs.readFileSync.mockReturnValue(Buffer.from('{"a":1}\nnot json\n{"b":2}\n'));
|
|
83
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
84
|
+
|
|
85
|
+
await trainLoraCommand(config, [
|
|
86
|
+
'--base-model', 'mistralai/Mistral-7B-v0.1',
|
|
87
|
+
'--dataset', './bad.jsonl',
|
|
88
|
+
'--max-cost', '10',
|
|
89
|
+
], chalk);
|
|
90
|
+
|
|
91
|
+
expect(process.exitCode).toBe(1);
|
|
92
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
93
|
+
const logged = console.error.mock.calls.flat().join('\n');
|
|
94
|
+
expect(logged).toContain('not valid JSONL');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('rejects an empty .jsonl dataset', async () => {
|
|
98
|
+
fs.readFileSync.mockReturnValue(Buffer.from(' \n \n'));
|
|
99
|
+
|
|
100
|
+
await trainLoraCommand(config, [
|
|
101
|
+
'--base-model', 'mistralai/Mistral-7B-v0.1',
|
|
102
|
+
'--dataset', './empty.jsonl',
|
|
103
|
+
'--max-cost', '10',
|
|
104
|
+
], chalk);
|
|
105
|
+
|
|
106
|
+
expect(process.exitCode).toBe(1);
|
|
107
|
+
const logged = console.error.mock.calls.flat().join('\n');
|
|
108
|
+
expect(logged).toContain('empty');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('uploads a valid .jsonl dataset and caches the resulting upload id', async () => {
|
|
112
|
+
fs.readFileSync.mockReturnValue(Buffer.from('{"a":1}\n{"b":2}\n'));
|
|
113
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
114
|
+
ok: true, json: async () => ({ upload_id: 'up_new_1' }),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await runTrainLora([
|
|
118
|
+
'--base-model', 'mistralai/Mistral-7B-v0.1',
|
|
119
|
+
'--dataset', './good.jsonl',
|
|
120
|
+
'--max-cost', '10',
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
124
|
+
expect(store.setCachedUploadId).toHaveBeenCalledWith(expect.any(String), 'up_new_1', expect.any(Object));
|
|
125
|
+
expect(api.callApi).toHaveBeenCalledWith('/jobs', expect.objectContaining({
|
|
126
|
+
body: expect.objectContaining({ input: expect.objectContaining({ dataset_file_id: 'up_new_1' }) }),
|
|
127
|
+
}));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('skips re-upload when the dataset content matches a cached hash', async () => {
|
|
131
|
+
fs.readFileSync.mockReturnValue(Buffer.from('{"a":1}\n{"b":2}\n'));
|
|
132
|
+
store.getCachedUploadId.mockReturnValue('up_cached_1');
|
|
133
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
134
|
+
|
|
135
|
+
await runTrainLora([
|
|
136
|
+
'--base-model', 'mistralai/Mistral-7B-v0.1',
|
|
137
|
+
'--dataset', './good.jsonl',
|
|
138
|
+
'--max-cost', '10',
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
142
|
+
expect(api.callApi).toHaveBeenCalledWith('/jobs', expect.objectContaining({
|
|
143
|
+
body: expect.objectContaining({ input: expect.objectContaining({ dataset_file_id: 'up_cached_1' }) }),
|
|
144
|
+
}));
|
|
145
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
146
|
+
expect(logged).toContain('reusing upload');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('trainLoraCommand --resume', () => {
|
|
151
|
+
it('threads resume_from_checkpoint_url into the job input', async () => {
|
|
152
|
+
await runTrainLora([
|
|
153
|
+
'--base-model', 'mistralai/Mistral-7B-v0.1',
|
|
154
|
+
'--dataset', 'https://example.com/data.jsonl',
|
|
155
|
+
'--max-cost', '10',
|
|
156
|
+
'--resume', 'https://cdn/ckpt.tar.gz',
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
expect(api.callApi).toHaveBeenCalledWith('/jobs', expect.objectContaining({
|
|
160
|
+
body: expect.objectContaining({
|
|
161
|
+
input: expect.objectContaining({ resume_from_checkpoint_url: 'https://cdn/ckpt.tar.gz' }),
|
|
162
|
+
}),
|
|
163
|
+
}));
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('omits resume_from_checkpoint_url when --resume is not given', async () => {
|
|
167
|
+
await runTrainLora([
|
|
168
|
+
'--base-model', 'mistralai/Mistral-7B-v0.1',
|
|
169
|
+
'--dataset', 'https://example.com/data.jsonl',
|
|
170
|
+
'--max-cost', '10',
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
const call = api.callApi.mock.calls.find(c => c[0] === '/jobs');
|
|
174
|
+
expect(call[1].body.input.resume_from_checkpoint_url).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
});
|