badgr-cli 1.0.35 → 1.0.36

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.
@@ -388,7 +388,13 @@ export async function runCommand(config, args, chalk) {
388
388
  const inferredImage = isSmoke ? 'python:3.11-alpine' : 'python:3.11-slim';
389
389
  const image = flags.image || (command ? inferredImage : undefined);
390
390
  const detach = flags.detach || false;
391
- const maxRuntimeMs = flags.maxRuntime ? flags.maxRuntime * 60 * 1000 : null;
391
+
392
+ // Default max-runtime of 60 minutes — always applied unless overridden.
393
+ const DEFAULT_JOB_RUNTIME_MIN = 60;
394
+ const isDefaultRuntime = flags.maxRuntime === undefined;
395
+ const effectiveMaxRuntime = flags.maxRuntime ?? DEFAULT_JOB_RUNTIME_MIN;
396
+ const maxRuntimeMs = effectiveMaxRuntime * 60 * 1000;
397
+
392
398
  const maxCost = flags.maxCost ?? null;
393
399
  const envObj = parseEnvFlag(flags.env);
394
400
  const effectiveTier = normalizeTier(flags.tier);
@@ -400,18 +406,17 @@ export async function runCommand(config, args, chalk) {
400
406
  if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
401
407
  if (gpu) console.log(` ${chalk.bold('GPU:')} ${gpu}`);
402
408
  else console.log(` ${chalk.bold('GPU:')} ${chalk.dim('auto')}`);
403
- if (flags.minVram) console.log(` ${chalk.bold('Min VRAM:')} ${flags.minVram} GB`);
404
- if (flags.maxRuntime) console.log(` ${chalk.bold('Max runtime:')} ${flags.maxRuntime}min`);
405
- if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
406
- if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice.toFixed(2)}/hr`);
407
- if (detach) console.log(` ${chalk.dim('(detached — returns immediately)')}`);
409
+ if (flags.minVram) console.log(` ${chalk.bold('Min VRAM:')} ${flags.minVram} GB`);
410
+ const runtimeLabel = isDefaultRuntime
411
+ ? `${effectiveMaxRuntime}min ${chalk.dim('(default — use --max-runtime N to override)')}`
412
+ : `${effectiveMaxRuntime}min`;
413
+ console.log(` ${chalk.bold('Max runtime:')} ${runtimeLabel}`);
414
+ if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
415
+ if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice.toFixed(2)}/hr`);
416
+ if (detach) console.log(` ${chalk.dim('(detached — returns immediately)')}`);
408
417
  if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
409
418
  console.log();
410
419
 
411
- if (!detach && !flags.maxRuntime && !maxCost) {
412
- console.log(chalk.dim(' Tip: add --max-runtime 60 or --max-cost 5.00 to cap spend automatically'));
413
- }
414
-
415
420
 
416
421
  console.log(chalk.dim(' Finding suitable capacity...'));
417
422
  if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
@@ -431,6 +436,8 @@ export async function runCommand(config, args, chalk) {
431
436
  name: flags.name,
432
437
  tier: tierOverride || effectiveTier,
433
438
  ...(Object.keys(envObj).length > 0 ? { env: envObj } : {}),
439
+ max_runtime_seconds: effectiveMaxRuntime * 60,
440
+ ...(maxCost ? { max_cost_usd: maxCost } : {}),
434
441
  };
435
442
  }
436
443
 
@@ -1,5 +1,5 @@
1
1
  import { requireApiKey } from '../config.js';
2
- import { callApi } from '../api.js';
2
+ import { callApi, listDeployments } from '../api.js';
3
3
  import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../store.js';
4
4
  import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
5
5
  import { formatCliError } from '../errors.js';
@@ -33,6 +33,8 @@ export function parseServeArgs(args) {
33
33
  if (args[i] === '--no-fallback' ||
34
34
  args[i] === '--strict-capacity' ||
35
35
  args[i] === '--no-expanded-search') { flags.noMarketplaceFallback = true; i++; continue; }
36
+ if (args[i] === '--persistent') { flags.persistent = true; i++; continue; }
37
+ if (args[i] === '--yes' || args[i] === '-y') { flags.yes = true; i++; continue; }
36
38
  if (args[i] === '--env') {
37
39
  const kv = args[++i]; i++;
38
40
  if (!flags.env) flags.env = [];
@@ -159,6 +161,7 @@ const _KNOWN_SERVE_FLAGS = new Set([
159
161
  '--gpu', '--image', '--task', '--count', '--region', '--tier', '--max-price',
160
162
  '--name', '--no-wait', '--max-cost', '--health-path', '--check-nodes',
161
163
  '--no-fallback', '--strict-capacity', '--no-expanded-search', '--env',
164
+ '--persistent', '--yes', '-y',
162
165
  ]);
163
166
 
164
167
  export async function serveCommand(config, args, chalk) {
@@ -211,6 +214,18 @@ export async function serveCommand(config, args, chalk) {
211
214
  return;
212
215
  }
213
216
 
217
+ // Endpoints bill continuously — require explicit cost control.
218
+ if (!flags.maxCost && !flags.persistent) {
219
+ const example = model || (customImage ? '--image ...' : '<model>');
220
+ console.error(chalk.red('\n ✗ Endpoints bill continuously until stopped. Specify a spending limit:\n'));
221
+ console.error(chalk.dim(` --max-cost 5 auto-stop when $5 is reached`));
222
+ console.error(chalk.dim(` --persistent run until you stop it manually\n`));
223
+ console.error(chalk.dim(` Example:`));
224
+ console.error(chalk.dim(` badgr serve ${example} --max-cost 10\n`));
225
+ process.exitCode = 1;
226
+ return;
227
+ }
228
+
214
229
  const envObj = parseEnvFlag(flags.env);
215
230
 
216
231
  const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : (customImage ? 'L40S' : 'AUTO');
@@ -236,12 +251,49 @@ export async function serveCommand(config, args, chalk) {
236
251
  console.log();
237
252
  console.log(` ${chalk.bold('Estimated workload:')} ${prof.label}`);
238
253
  console.log(` ${chalk.bold('Estimated minimum VRAM:')} ${prof.vram}`);
239
- if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
240
254
  }
241
255
  }
256
+ if (flags.maxCost) {
257
+ console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)} (auto-stop)`);
258
+ } else {
259
+ console.log(chalk.yellow(` ⚠ Persistent — billing until: badgr down <id> or badgr down --all`));
260
+ }
242
261
  console.log();
243
262
  process.stdout.write(chalk.dim(' Finding suitable capacity...\n'));
244
263
 
264
+ // ── Duplicate check ────────────────────────────────────────────────────────
265
+ if (config.apiKey) {
266
+ try {
267
+ const existing = await listDeployments(config);
268
+ const ACTIVE = new Set(['running', 'provisioning', 'starting', 'queued']);
269
+ const duplicate = (existing.deployments ?? []).find(d =>
270
+ ACTIVE.has(d.status) &&
271
+ d.workload_type === 'endpoint' &&
272
+ (
273
+ (model && d.model === model) ||
274
+ (customImage && d.image === customImage)
275
+ )
276
+ );
277
+ if (duplicate) {
278
+ console.log(chalk.yellow(`\n ⚠ This workload is already running:\n`));
279
+ console.log(` ${chalk.cyan(duplicate.deployment_id)} ${duplicate.gpu_type || ''} $${(duplicate.cost_per_hour || 0).toFixed(2)}/hr`);
280
+ if (duplicate.endpoint_url) console.log(` URL: ${chalk.cyan(duplicate.endpoint_url)}`);
281
+ console.log();
282
+ console.log(chalk.dim(` Reuse: Use the URL above`));
283
+ console.log(chalk.dim(` Stop first: badgr down ${duplicate.deployment_id}`));
284
+ console.log(chalk.dim(` Launch new: add --yes to this command`));
285
+ console.log();
286
+ if (!flags.yes) {
287
+ process.exitCode = 1;
288
+ return;
289
+ }
290
+ console.log(chalk.dim(' Launching another anyway (--yes).\n'));
291
+ }
292
+ } catch {
293
+ // non-fatal — skip duplicate check if API is unreachable
294
+ }
295
+ }
296
+
245
297
  const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
246
298
 
247
299
  function buildBody(gpuOverride, tierOverride) {
@@ -256,6 +308,7 @@ export async function serveCommand(config, args, chalk) {
256
308
  name: flags.name,
257
309
  tier: tierOverride || effectiveTier,
258
310
  ...(Object.keys(envObj).length > 0 ? { env: envObj } : {}),
311
+ ...(flags.maxCost ? { max_cost_usd: flags.maxCost } : {}),
259
312
  };
260
313
  }
261
314
 
@@ -0,0 +1,255 @@
1
+ /**
2
+ * badgr train config.yaml [--gpu A100] [--max-cost N] [--max-runtime N]
3
+ *
4
+ * Runs a LoRA/QLoRA/fine-tuning job on GPU.
5
+ * Detects the training framework from config.yaml and selects the right image.
6
+ * Enforces a max-runtime (default 120 min) and streams logs until completion.
7
+ */
8
+ import { readFileSync, existsSync } from 'fs';
9
+ import { requireApiKey } from '../config.js';
10
+ import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
11
+ import { normalizeTier, callWithFallback } from '../fallback.js';
12
+ import { monitorBatchJob, fmtRuntime } from '../batch.js';
13
+
14
+ const MAX_CONFIG_B = 512 * 1024; // 512 KB config limit
15
+
16
+ // Training images keyed by framework name.
17
+ const TRAINING_IMAGES = {
18
+ axolotl: 'winglian/axolotl:main-latest',
19
+ unsloth: 'unslothai/unsloth:latest',
20
+ trl: 'huggingface/trl-source:latest',
21
+ generic: 'nvidia/cuda:12.1.0-cudnn8-devel-ubuntu22.04',
22
+ };
23
+
24
+ // Preferred GPUs for training: VRAM-heavy workloads.
25
+ const TRAINING_GPUS = ['A100', 'H100', 'L40S', 'A6000'];
26
+
27
+ const DEFAULT_MAX_RUNTIME_MIN = 120;
28
+
29
+ export function parseTrainArgs(args) {
30
+ const flags = {};
31
+ const positional = [];
32
+ let i = 0;
33
+ while (i < args.length) {
34
+ const a = args[i];
35
+ if (a === '--gpu') { flags.gpu = args[++i]; i++; continue; }
36
+ if (a === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
37
+ if (a === '--max-price') { flags.maxPrice = parseFloat(args[++i]); i++; continue; }
38
+ if (a === '--max-runtime') { flags.maxRuntime = parseFloat(args[++i]); i++; continue; }
39
+ if (a === '--tier') { flags.tier = args[++i]; i++; continue; }
40
+ if (a === '--region') { flags.region = args[++i]; i++; continue; }
41
+ if (a === '--framework') { flags.framework = args[++i]; i++; continue; }
42
+ if (a === '--detach') { flags.detach = true; i++; continue; }
43
+ if (a === '--env') {
44
+ const kv = args[++i]; i++;
45
+ if (!flags.env) flags.env = [];
46
+ flags.env.push(kv);
47
+ continue;
48
+ }
49
+ positional.push(args[i++]);
50
+ }
51
+ return { configFile: positional[0] || null, flags };
52
+ }
53
+
54
+ function parseEnvFlag(envList) {
55
+ const obj = {};
56
+ for (const kv of (envList || [])) {
57
+ const idx = kv.indexOf('=');
58
+ if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
59
+ }
60
+ return obj;
61
+ }
62
+
63
+ /**
64
+ * Detect training framework from config content.
65
+ * Returns 'axolotl' | 'unsloth' | 'trl' | 'generic'.
66
+ */
67
+ export function detectFramework(configContent) {
68
+ const s = configContent.toLowerCase();
69
+ if (/axolotl|base_model.*:\s*\S+/.test(s) && !/unsloth/.test(s)) return 'axolotl';
70
+ if (/unsloth/.test(s)) return 'unsloth';
71
+ if (/\btrl\b|sfttrainer|dpotrainer|ppotrainer/.test(s)) return 'trl';
72
+ return 'generic';
73
+ }
74
+
75
+ /**
76
+ * Warn if config references local dataset paths that won't be accessible remotely.
77
+ */
78
+ export function findLocalDatasetPaths(configContent) {
79
+ const paths = [];
80
+ // Match: path: ./foo or path: /absolute/path (not s3://, gs://, hf://, https://)
81
+ // Handles both bare `path:` and YAML list-item `- path:` prefixes.
82
+ const re = /^\s*(?:-\s+)?path:\s*(?!s3:|gs:|hf:|https?:|huggingface\/)(['"]?)([./][^\s'"#]+)\1/gm;
83
+ let m;
84
+ while ((m = re.exec(configContent)) !== null) {
85
+ paths.push(m[2]);
86
+ }
87
+ return paths;
88
+ }
89
+
90
+ export async function trainCommand(config, args, chalk) {
91
+ const { configFile, flags } = parseTrainArgs(args);
92
+
93
+ if (!configFile) {
94
+ console.error(chalk.red('\n Usage: badgr train config.yaml\n'));
95
+ console.error(chalk.dim(' Runs LoRA / fine-tuning on GPU and streams logs.\n'));
96
+ process.exitCode = 1;
97
+ return;
98
+ }
99
+
100
+ requireApiKey(config);
101
+
102
+ if (!existsSync(configFile)) {
103
+ console.error(chalk.red(`\n ✗ Config file not found: ${configFile}\n`));
104
+ process.exitCode = 1;
105
+ return;
106
+ }
107
+
108
+ let configRaw;
109
+ try {
110
+ configRaw = readFileSync(configFile, 'utf8');
111
+ } catch (err) {
112
+ console.error(chalk.red(`\n ✗ Could not read ${configFile}: ${err.message}\n`));
113
+ process.exitCode = 1;
114
+ return;
115
+ }
116
+
117
+ if (Buffer.byteLength(configRaw) > MAX_CONFIG_B) {
118
+ console.error(chalk.red(`\n ✗ Config exceeds 512 KB (${(Buffer.byteLength(configRaw) / 1024).toFixed(0)} KB).\n`));
119
+ process.exitCode = 1;
120
+ return;
121
+ }
122
+
123
+ const framework = flags.framework || detectFramework(configRaw);
124
+ const image = TRAINING_IMAGES[framework] || TRAINING_IMAGES.generic;
125
+ const configB64 = Buffer.from(configRaw).toString('base64');
126
+ const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : 'AUTO';
127
+ const effectiveTier = normalizeTier(flags.tier);
128
+
129
+ const isDefaultRuntime = flags.maxRuntime === undefined;
130
+ const maxRuntimeMin = flags.maxRuntime ?? DEFAULT_MAX_RUNTIME_MIN;
131
+ const maxRuntimeMs = maxRuntimeMin * 60 * 1000;
132
+ const maxCost = flags.maxCost ?? null;
133
+
134
+ const envObj = {
135
+ ...parseEnvFlag(flags.env),
136
+ TRAIN_CONFIG_B64: configB64,
137
+ TRAIN_FRAMEWORK: framework,
138
+ };
139
+
140
+ // Warn about local dataset paths that won't be accessible from the cloud.
141
+ const localPaths = findLocalDatasetPaths(configRaw);
142
+ if (localPaths.length > 0) {
143
+ console.log(chalk.yellow('\n ⚠ Config references local paths — these won\'t be accessible from the cloud:\n'));
144
+ for (const p of localPaths) {
145
+ console.log(chalk.yellow(` ${p}`));
146
+ }
147
+ console.log(chalk.dim('\n Upload your dataset to Hugging Face or S3 and update the path in your config.\n'));
148
+ }
149
+
150
+ console.log(chalk.bold('\n🏋️ Training\n'));
151
+ console.log(` ${chalk.bold('Config:')} ${configFile}`);
152
+ console.log(` ${chalk.bold('Framework:')} ${framework}`);
153
+ console.log(` ${chalk.bold('Image:')} ${image}`);
154
+ console.log(` ${chalk.bold('GPU:')} ${gpu === 'AUTO' ? chalk.dim('auto (40+ GB VRAM preferred)') : gpu}`);
155
+ const runtimeLabel = isDefaultRuntime
156
+ ? `${maxRuntimeMin}min ${chalk.dim('(default — use --max-runtime N to override)')}`
157
+ : `${maxRuntimeMin}min`;
158
+ console.log(` ${chalk.bold('Max runtime:')} ${runtimeLabel}`);
159
+ if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
160
+ console.log();
161
+ process.stdout.write(chalk.dim(' Finding suitable capacity...\n'));
162
+
163
+ function buildBody(tierOverride) {
164
+ return {
165
+ image,
166
+ command: ['sh', '-c', 'echo "$TRAIN_CONFIG_B64" | base64 -d > /tmp/config.yaml && axolotl train /tmp/config.yaml'],
167
+ gpu,
168
+ gpu_count: 1,
169
+ ...(flags.region ? { region: flags.region.toUpperCase() } : {}),
170
+ max_price_per_hour: flags.maxPrice,
171
+ tier: tierOverride || effectiveTier,
172
+ env: envObj,
173
+ max_runtime_seconds: maxRuntimeMin * 60,
174
+ ...(maxCost ? { max_cost_usd: maxCost } : {}),
175
+ };
176
+ }
177
+
178
+ let dep;
179
+ try {
180
+ dep = await callWithFallback(
181
+ '/run',
182
+ { apiKey: config.apiKey, baseUrl: config.baseUrl },
183
+ (tierOverride) => buildBody(tierOverride),
184
+ effectiveTier,
185
+ chalk,
186
+ { thing: 'training job', cmd: 'badgr train' },
187
+ );
188
+ } catch (err) {
189
+ if (err.isPaymentRequired) {
190
+ console.error(chalk.yellow(err.message));
191
+ console.error(chalk.dim(`After payment, rerun:\n badgr train ${args.join(' ')}\n`));
192
+ process.exitCode = 1;
193
+ return;
194
+ }
195
+ console.error(err.message);
196
+ process.exitCode = 1;
197
+ return;
198
+ }
199
+
200
+ const rcptId = dep.receipt_id || generateReceiptId();
201
+ addReceipt({
202
+ receiptId: rcptId,
203
+ action: 'badgr train',
204
+ deploymentId: dep.deployment_id,
205
+ gpu: dep.gpu_type,
206
+ providerRoute: dep.provider ?? null,
207
+ tier: dep.tier ?? null,
208
+ maxCost,
209
+ maxRuntime: maxRuntimeMin,
210
+ status: dep.status,
211
+ createdAt: new Date().toISOString(),
212
+ });
213
+
214
+ const rate = dep.cost_per_hour || 0;
215
+ console.log(chalk.dim(' Capacity found.\n'));
216
+ console.log(chalk.bold(` Job ID: ${chalk.cyan(dep.deployment_id)}`));
217
+ console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
218
+ if (rate > 0) console.log(` ${chalk.bold('Rate:')} $${rate.toFixed(2)}/hr`);
219
+ if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
220
+ console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
221
+
222
+ if (flags.detach) {
223
+ console.log(`\n ${chalk.bold('Logs:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
224
+ console.log(chalk.dim(`\n Detached. Monitor: badgr logs ${dep.deployment_id}\n`));
225
+ return;
226
+ }
227
+
228
+ const result = await monitorBatchJob(config, dep.deployment_id, rcptId, {
229
+ chalk,
230
+ maxRuntimeMs,
231
+ maxCost,
232
+ ratePerHour: rate,
233
+ });
234
+
235
+ console.log();
236
+ const finalCost = rate * (result.runtimeMs / 3_600_000);
237
+ console.log(` ${chalk.bold('Runtime:')} ${fmtRuntime(result.runtimeMs)}`);
238
+ if (rate > 0) console.log(` ${chalk.bold('Cost:')} $${finalCost.toFixed(4)}`);
239
+ if (result.exitCode !== null && result.exitCode !== undefined) {
240
+ console.log(` ${chalk.bold('Exit code:')} ${result.exitCode !== 0 ? chalk.red(result.exitCode) : chalk.green(result.exitCode)}`);
241
+ }
242
+ console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
243
+
244
+ if (result.reason === 'complete' &&
245
+ (result.status === 'completed' || result.status === 'succeeded') &&
246
+ (result.exitCode === 0 || result.exitCode === null)) {
247
+ console.log(chalk.green('\n ✓ Training complete\n'));
248
+ } else if (result.reason === 'interrupted') {
249
+ process.exitCode = 0;
250
+ } else if (result.status === 'failed' || (result.exitCode !== null && result.exitCode !== 0)) {
251
+ console.error(chalk.red('\n ✗ Training failed\n'));
252
+ console.error(chalk.dim(` Logs: badgr logs ${dep.deployment_id}\n`));
253
+ process.exitCode = result.exitCode ?? 1;
254
+ }
255
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * badgr transcribe <audio-url-or-file> [--model large-v3] [--max-cost N]
3
+ *
4
+ * Runs Whisper transcription on GPU.
5
+ * Accepts a public URL, S3/GCS URI, or a local file under 50 MB (base64-encoded inline).
6
+ * Transcript appears in streaming logs and is written to stdout by the container.
7
+ */
8
+ import { readFileSync, existsSync, statSync } from 'fs';
9
+ import { requireApiKey } from '../config.js';
10
+ import { addReceipt, generateReceiptId } from '../store.js';
11
+ import { normalizeTier, callWithFallback } from '../fallback.js';
12
+ import { monitorBatchJob, fmtRuntime } from '../batch.js';
13
+
14
+ const WHISPER_IMAGE = 'fedirz/faster-whisper-server:latest-cuda';
15
+ const DEFAULT_WHISPER_MODEL = 'large-v3';
16
+ const DEFAULT_MAX_RUNTIME_MIN = 30;
17
+ const MAX_INLINE_B = 50 * 1024 * 1024; // 50 MB inline limit
18
+
19
+ export function parseTranscribeArgs(args) {
20
+ const flags = {};
21
+ const positional = [];
22
+ let i = 0;
23
+ while (i < args.length) {
24
+ const a = args[i];
25
+ if (a === '--model') { flags.model = args[++i]; i++; continue; }
26
+ if (a === '--gpu') { flags.gpu = args[++i]; i++; continue; }
27
+ if (a === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
28
+ if (a === '--max-price') { flags.maxPrice = parseFloat(args[++i]); i++; continue; }
29
+ if (a === '--max-runtime') { flags.maxRuntime = parseFloat(args[++i]); i++; continue; }
30
+ if (a === '--tier') { flags.tier = args[++i]; i++; continue; }
31
+ if (a === '--region') { flags.region = args[++i]; i++; continue; }
32
+ if (a === '--language') { flags.language = args[++i]; i++; continue; }
33
+ if (a === '--output') { flags.output = args[++i]; i++; continue; }
34
+ if (a === '--detach') { flags.detach = true; i++; continue; }
35
+ if (a === '--env') {
36
+ const kv = args[++i]; i++;
37
+ if (!flags.env) flags.env = [];
38
+ flags.env.push(kv);
39
+ continue;
40
+ }
41
+ positional.push(args[i++]);
42
+ }
43
+ return { input: positional[0] || null, flags };
44
+ }
45
+
46
+ function parseEnvFlag(envList) {
47
+ const obj = {};
48
+ for (const kv of (envList || [])) {
49
+ const idx = kv.indexOf('=');
50
+ if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
51
+ }
52
+ return obj;
53
+ }
54
+
55
+ function isUrl(s) {
56
+ return /^(https?|s3|gs|hf):\/\//i.test(s);
57
+ }
58
+
59
+ /**
60
+ * Resolve input to { audioUrl, audioB64, inputLabel }.
61
+ * Returns null and sets an error message if the input is invalid.
62
+ */
63
+ export function resolveAudioInput(input) {
64
+ if (isUrl(input)) {
65
+ return { audioUrl: input, audioB64: null, inputLabel: input };
66
+ }
67
+ if (!existsSync(input)) {
68
+ return { error: `File not found: ${input}` };
69
+ }
70
+ const size = statSync(input).size;
71
+ if (size > MAX_INLINE_B) {
72
+ return {
73
+ error: `File too large for inline transfer (${(size / 1024 / 1024).toFixed(1)} MB > 50 MB limit).\n` +
74
+ ' Upload to S3 or a public URL and pass the URL instead.',
75
+ };
76
+ }
77
+ const audioB64 = readFileSync(input).toString('base64');
78
+ return { audioUrl: null, audioB64, inputLabel: input };
79
+ }
80
+
81
+ export async function transcribeCommand(config, args, chalk) {
82
+ const { input, flags } = parseTranscribeArgs(args);
83
+
84
+ if (!input) {
85
+ console.error(chalk.red('\n Usage: badgr transcribe <audio-url-or-file>\n'));
86
+ console.error(chalk.dim(' Examples:'));
87
+ console.error(chalk.dim(' badgr transcribe s3://my-bucket/meeting.mp3'));
88
+ console.error(chalk.dim(' badgr transcribe https://example.com/lecture.mp4'));
89
+ console.error(chalk.dim(' badgr transcribe recording.mp3 --max-cost 2\n'));
90
+ process.exitCode = 1;
91
+ return;
92
+ }
93
+
94
+ requireApiKey(config);
95
+
96
+ const resolved = resolveAudioInput(input);
97
+ if (resolved.error) {
98
+ console.error(chalk.red(`\n ✗ ${resolved.error}\n`));
99
+ process.exitCode = 1;
100
+ return;
101
+ }
102
+
103
+ const whisperModel = flags.model || DEFAULT_WHISPER_MODEL;
104
+ const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : 'AUTO';
105
+ const effectiveTier = normalizeTier(flags.tier);
106
+ const maxRuntimeMin = flags.maxRuntime ?? DEFAULT_MAX_RUNTIME_MIN;
107
+ const maxRuntimeMs = maxRuntimeMin * 60 * 1000;
108
+ const maxCost = flags.maxCost ?? null;
109
+
110
+ const envObj = {
111
+ ...parseEnvFlag(flags.env),
112
+ WHISPER_MODEL: whisperModel,
113
+ ...(resolved.audioUrl ? { AUDIO_URL: resolved.audioUrl } : {}),
114
+ ...(resolved.audioB64 ? { AUDIO_B64: resolved.audioB64 } : {}),
115
+ ...(flags.language ? { WHISPER_LANGUAGE: flags.language } : {}),
116
+ ...(flags.output ? { WHISPER_OUTPUT_FORMAT: flags.output } : {}),
117
+ };
118
+
119
+ console.log(chalk.bold('\n🎙 Transcription\n'));
120
+ console.log(` ${chalk.bold('Input:')} ${resolved.inputLabel}`);
121
+ console.log(` ${chalk.bold('Model:')} ${whisperModel}`);
122
+ console.log(` ${chalk.bold('Image:')} ${WHISPER_IMAGE}`);
123
+ console.log(` ${chalk.bold('GPU:')} ${gpu === 'AUTO' ? chalk.dim('auto (8+ GB VRAM)') : gpu}`);
124
+ console.log(` ${chalk.bold('Max runtime:')} ${maxRuntimeMin}min`);
125
+ if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
126
+ if (flags.language) console.log(` ${chalk.bold('Language:')} ${flags.language}`);
127
+ console.log();
128
+ process.stdout.write(chalk.dim(' Finding suitable capacity...\n'));
129
+
130
+ function buildBody(tierOverride) {
131
+ return {
132
+ image: WHISPER_IMAGE,
133
+ command: ['sh', '-c', [
134
+ resolved.audioB64
135
+ ? 'echo "$AUDIO_B64" | base64 -d > /tmp/audio_input && export AUDIO_PATH=/tmp/audio_input'
136
+ : 'export AUDIO_PATH="$AUDIO_URL"',
137
+ 'whisper "$AUDIO_PATH" --model "$WHISPER_MODEL"' +
138
+ (flags.language ? ' --language "$WHISPER_LANGUAGE"' : '') +
139
+ (flags.output ? ' --output_format "$WHISPER_OUTPUT_FORMAT"' : ''),
140
+ ].join(' && ')],
141
+ gpu,
142
+ gpu_count: 1,
143
+ ...(flags.region ? { region: flags.region.toUpperCase() } : {}),
144
+ max_price_per_hour: flags.maxPrice,
145
+ tier: tierOverride || effectiveTier,
146
+ env: envObj,
147
+ max_runtime_seconds: maxRuntimeMin * 60,
148
+ ...(maxCost ? { max_cost_usd: maxCost } : {}),
149
+ };
150
+ }
151
+
152
+ let dep;
153
+ try {
154
+ dep = await callWithFallback(
155
+ '/run',
156
+ { apiKey: config.apiKey, baseUrl: config.baseUrl },
157
+ (tierOverride) => buildBody(tierOverride),
158
+ effectiveTier,
159
+ chalk,
160
+ { thing: 'transcription job', cmd: 'badgr transcribe' },
161
+ );
162
+ } catch (err) {
163
+ if (err.isPaymentRequired) { console.error(chalk.yellow(err.message)); process.exitCode = 1; return; }
164
+ console.error(err.message);
165
+ process.exitCode = 1;
166
+ return;
167
+ }
168
+
169
+ const rcptId = dep.receipt_id || generateReceiptId();
170
+ addReceipt({
171
+ receiptId: rcptId,
172
+ action: 'badgr transcribe',
173
+ deploymentId: dep.deployment_id,
174
+ gpu: dep.gpu_type,
175
+ providerRoute: dep.provider ?? null,
176
+ tier: dep.tier ?? null,
177
+ maxCost,
178
+ maxRuntime: maxRuntimeMin,
179
+ status: dep.status,
180
+ createdAt: new Date().toISOString(),
181
+ });
182
+
183
+ const rate = dep.cost_per_hour || 0;
184
+ console.log(chalk.dim(' Capacity found.\n'));
185
+ console.log(chalk.bold(` Job ID: ${chalk.cyan(dep.deployment_id)}`));
186
+ console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
187
+ if (rate > 0) console.log(` ${chalk.bold('Rate:')} $${rate.toFixed(2)}/hr`);
188
+ console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
189
+
190
+ if (flags.detach) {
191
+ console.log(`\n ${chalk.bold('Logs:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
192
+ console.log(chalk.dim(`\n Detached. View transcript: badgr logs ${dep.deployment_id}\n`));
193
+ return;
194
+ }
195
+
196
+ const result = await monitorBatchJob(config, dep.deployment_id, rcptId, {
197
+ chalk,
198
+ maxRuntimeMs,
199
+ maxCost,
200
+ ratePerHour: rate,
201
+ });
202
+
203
+ console.log();
204
+ const finalCost = rate * (result.runtimeMs / 3_600_000);
205
+ console.log(` ${chalk.bold('Runtime:')} ${fmtRuntime(result.runtimeMs)}`);
206
+ if (rate > 0) console.log(` ${chalk.bold('Cost:')} $${finalCost.toFixed(4)}`);
207
+ console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
208
+
209
+ if (result.reason === 'complete' &&
210
+ (result.status === 'completed' || result.status === 'succeeded') &&
211
+ (result.exitCode === 0 || result.exitCode === null)) {
212
+ console.log(chalk.green('\n ✓ Transcription complete\n'));
213
+ console.log(chalk.dim(' Transcript appears above in the log output.'));
214
+ console.log(chalk.dim(` Full logs: badgr logs ${dep.deployment_id}\n`));
215
+ } else if (result.reason !== 'interrupted' &&
216
+ (result.status === 'failed' || (result.exitCode !== null && result.exitCode !== 0))) {
217
+ console.error(chalk.red('\n ✗ Transcription failed\n'));
218
+ console.error(chalk.dim(` Logs: badgr logs ${dep.deployment_id}\n`));
219
+ process.exitCode = result.exitCode ?? 1;
220
+ }
221
+ }
@@ -324,3 +324,54 @@ describe('compat_failure error message', () => {
324
324
  expect(thrown.message).toMatch(/GPU type|image/i);
325
325
  });
326
326
  });
327
+
328
+ // ─────────────────────────────────────────────────────────────────────────────
329
+ // 4. Stale-capacity retry — PROVISIONING_FAILED triggers one immediate retry
330
+ // ─────────────────────────────────────────────────────────────────────────────
331
+
332
+ describe('stale-capacity retry on PROVISIONING_FAILED', () => {
333
+ function makeProvisioningFailedErr() {
334
+ const err = new Error('PROVISIONING_FAILED');
335
+ err.errorData = { code: 'PROVISIONING_FAILED', failure_category: 'infrastructure' };
336
+ return err;
337
+ }
338
+
339
+ it('retries the API call once before escalating on PROVISIONING_FAILED (tier=2 → no tier-2 expansion)', async () => {
340
+ // tier=2 means no tier-2 fallback expansion, so only the stale-capacity retry fires.
341
+ api.callApi.mockRejectedValue(makeProvisioningFailedErr());
342
+
343
+ try {
344
+ await callWithFallback(
345
+ '/run',
346
+ { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' },
347
+ () => ({}),
348
+ '2',
349
+ chalk,
350
+ { thing: 'job', cmd: 'badgr run' },
351
+ );
352
+ } catch (_) {}
353
+
354
+ // First attempt + one stale-capacity retry = 2 calls minimum
355
+ expect(api.callApi.mock.calls.length).toBeGreaterThanOrEqual(2);
356
+ });
357
+
358
+ it('does NOT double-retry on non-PROVISIONING_FAILED errors', async () => {
359
+ const err = new Error('NO_CAPACITY_MATCH');
360
+ err.errorData = { code: 'NO_CAPACITY_MATCH' };
361
+ api.callApi.mockRejectedValue(err);
362
+
363
+ try {
364
+ await callWithFallback(
365
+ '/run',
366
+ { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' },
367
+ () => ({}),
368
+ '2',
369
+ chalk,
370
+ { thing: 'job', cmd: 'badgr run' },
371
+ );
372
+ } catch (_) {}
373
+
374
+ // tier=2 → no tier-2 expansion; no stale-capacity retry for NO_CAPACITY_MATCH
375
+ expect(api.callApi.mock.calls.length).toBe(1);
376
+ });
377
+ });