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.
@@ -0,0 +1,318 @@
1
+ /**
2
+ * badgr comfyui run workflow.json [--gpu RTX_4090] [--max-cost N] [--check-nodes node1,node2]
3
+ *
4
+ * Launches ComfyUI, health-checks /system_stats, and returns the URL.
5
+ * The workflow file is base64-encoded into COMFYUI_WORKFLOW_B64 so the container
6
+ * can queue it at startup.
7
+ */
8
+ import { readFileSync, existsSync } from 'fs';
9
+ import { requireApiKey } from '../config.js';
10
+ import { callApi, listDeployments } from '../api.js';
11
+ import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../store.js';
12
+ import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
13
+ import { formatCliError } from '../errors.js';
14
+
15
+ const COMFYUI_IMAGE = 'yanwk/comfyui-boot:latest';
16
+ const HEALTH_PATH = '/system_stats';
17
+ const WAIT_TIMEOUT_MS = 10 * 60 * 1000; // 10 min — model downloads on first boot
18
+ const MAX_WORKFLOW_B = 1 * 1024 * 1024; // 1 MB workflow limit
19
+
20
+ export function parseComfyuiArgs(args) {
21
+ const flags = {};
22
+ const positional = [];
23
+ let i = 0;
24
+ while (i < args.length) {
25
+ const a = args[i];
26
+ if (a === 'run') { i++; continue; } // optional subcommand
27
+ if (a === '--gpu') { flags.gpu = args[++i]; i++; continue; }
28
+ if (a === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
29
+ if (a === '--max-price') { flags.maxPrice = 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 === '--no-wait') { flags.noWait = true; i++; continue; }
33
+ if (a === '--check-nodes') { flags.checkNodes = args[++i]; i++; continue; }
34
+ if (a === '--persistent') { flags.persistent = true; i++; continue; }
35
+ if (a === '--yes' || a === '-y') { flags.yes = true; i++; continue; }
36
+ if (a === '--env') {
37
+ const kv = args[++i]; i++;
38
+ if (!flags.env) flags.env = [];
39
+ flags.env.push(kv);
40
+ continue;
41
+ }
42
+ positional.push(args[i++]);
43
+ }
44
+ return { workflow: positional[0] || null, flags };
45
+ }
46
+
47
+ function parseEnvFlag(envList) {
48
+ const obj = {};
49
+ for (const kv of (envList || [])) {
50
+ const idx = kv.indexOf('=');
51
+ if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
52
+ }
53
+ return obj;
54
+ }
55
+
56
+ function stageLabel(elapsedSec) {
57
+ if (elapsedSec < 30) return 'Starting ComfyUI…';
58
+ if (elapsedSec < 120) return 'Downloading models…';
59
+ if (elapsedSec < 300) return 'Loading custom nodes…';
60
+ return `Waiting for ${HEALTH_PATH}…`;
61
+ }
62
+
63
+ async function waitForComfyUI(endpointUrl, depId, config, chalk) {
64
+ const startMs = Date.now();
65
+ const deadline = startMs + WAIT_TIMEOUT_MS;
66
+
67
+ while (Date.now() < deadline) {
68
+ try {
69
+ const dep = await callApi(`/deployments/${depId}`, {
70
+ apiKey: config.apiKey, baseUrl: config.baseUrl, timeoutMs: 10_000,
71
+ });
72
+ if (['failed', 'terminated', 'error', 'stopped'].includes(dep.status)) {
73
+ process.stdout.write('\n');
74
+ return { ready: false, timedOut: false, depFailed: true, failReason: dep.error || dep.status };
75
+ }
76
+ } catch {}
77
+
78
+ try {
79
+ const res = await fetch(`${endpointUrl}${HEALTH_PATH}`, { signal: AbortSignal.timeout(8000) });
80
+ if (res.ok) { process.stdout.write('\n'); return { ready: true, timedOut: false, depFailed: false }; }
81
+ } catch {}
82
+
83
+ const elapsed = Math.round((Date.now() - startMs) / 1000);
84
+ process.stdout.write(`\r ${chalk.dim(stageLabel(elapsed) + ` (${elapsed}s)`)} `);
85
+ await new Promise(r => setTimeout(r, 8000));
86
+ }
87
+
88
+ process.stdout.write('\n');
89
+ return { ready: false, timedOut: true, depFailed: false };
90
+ }
91
+
92
+ async function validateComfyNodes(endpointUrl, nodeList, chalk) {
93
+ const nodes = nodeList.split(',').map(n => n.trim()).filter(Boolean);
94
+ if (!nodes.length) return;
95
+ const objectInfoUrl = endpointUrl.replace(/\/v1\/?$/, '') + '/object_info';
96
+ console.log(chalk.dim(`\n Checking custom nodes via ${objectInfoUrl}…`));
97
+ try {
98
+ const res = await fetch(objectInfoUrl, { signal: AbortSignal.timeout(15_000) });
99
+ if (!res.ok) { console.log(chalk.yellow(` ⚠ Could not verify nodes (HTTP ${res.status})`)); return; }
100
+ const available = await res.json();
101
+ const found = nodes.filter(n => Object.prototype.hasOwnProperty.call(available, n));
102
+ const missing = nodes.filter(n => !Object.prototype.hasOwnProperty.call(available, n));
103
+ if (found.length) console.log(chalk.green(` ✓ Nodes confirmed: ${found.join(', ')}`));
104
+ if (missing.length) {
105
+ console.log(chalk.yellow(` ⚠ Nodes not found: ${missing.join(', ')}`));
106
+ console.log(chalk.dim(' These may not be installed in this image.'));
107
+ }
108
+ } catch (err) {
109
+ console.log(chalk.yellow(` ⚠ Node check failed: ${err.message}`));
110
+ }
111
+ }
112
+
113
+ export async function comfyuiCommand(config, args, chalk) {
114
+ const { workflow, flags } = parseComfyuiArgs(args);
115
+
116
+ if (!workflow) {
117
+ console.error(chalk.red('\n Usage: badgr comfyui run workflow.json\n'));
118
+ console.error(chalk.dim(' Launches ComfyUI, queues your workflow, and returns the URL.\n'));
119
+ process.exitCode = 1;
120
+ return;
121
+ }
122
+
123
+ requireApiKey(config);
124
+
125
+ if (!flags.maxCost && !flags.persistent) {
126
+ console.error(chalk.red('\n ✗ ComfyUI endpoints bill continuously. Specify a spending limit:\n'));
127
+ console.error(chalk.dim(' --max-cost 5 auto-stop when $5 is reached'));
128
+ console.error(chalk.dim(' --persistent run until you stop it manually\n'));
129
+ console.error(chalk.dim(' Example:'));
130
+ console.error(chalk.dim(` badgr comfyui run ${workflow} --max-cost 10\n`));
131
+ process.exitCode = 1;
132
+ return;
133
+ }
134
+
135
+ if (!existsSync(workflow)) {
136
+ console.error(chalk.red(`\n ✗ Workflow file not found: ${workflow}\n`));
137
+ process.exitCode = 1;
138
+ return;
139
+ }
140
+
141
+ let workflowRaw;
142
+ let workflowJson;
143
+ try {
144
+ workflowRaw = readFileSync(workflow, 'utf8');
145
+ workflowJson = JSON.parse(workflowRaw);
146
+ } catch (err) {
147
+ console.error(chalk.red(`\n ✗ Could not read ${workflow}: ${err.message}\n`));
148
+ process.exitCode = 1;
149
+ return;
150
+ }
151
+
152
+ if (Buffer.byteLength(workflowRaw) > MAX_WORKFLOW_B) {
153
+ console.error(chalk.red(`\n ✗ Workflow file exceeds 1 MB (${(Buffer.byteLength(workflowRaw) / 1024).toFixed(0)} KB).\n`));
154
+ console.error(chalk.dim(' Split your workflow into smaller segments.\n'));
155
+ process.exitCode = 1;
156
+ return;
157
+ }
158
+
159
+ const nodeCount = Object.keys(workflowJson).length;
160
+ const workflowB64 = Buffer.from(workflowRaw).toString('base64');
161
+
162
+ const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : 'AUTO';
163
+ const effectiveTier = normalizeTier(flags.tier);
164
+ const envObj = { ...parseEnvFlag(flags.env), COMFYUI_WORKFLOW_B64: workflowB64 };
165
+
166
+ console.log(chalk.bold('\n🎨 ComfyUI\n'));
167
+ console.log(` ${chalk.bold('Workflow:')} ${workflow} (${nodeCount} nodes)`);
168
+ console.log(` ${chalk.bold('Image:')} ${COMFYUI_IMAGE}`);
169
+ console.log(` ${chalk.bold('GPU:')} ${gpu === 'AUTO' ? chalk.dim('auto (16+ GB VRAM)') : gpu}`);
170
+ if (flags.maxCost) {
171
+ console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)} (auto-stop)`);
172
+ } else {
173
+ console.log(chalk.yellow(' ⚠ Persistent — billing until: badgr down <id>'));
174
+ }
175
+ console.log();
176
+ process.stdout.write(chalk.dim(' Finding suitable capacity...\n'));
177
+
178
+ // Duplicate check
179
+ if (config.apiKey) {
180
+ try {
181
+ const existing = await listDeployments(config);
182
+ const ACTIVE = new Set(['running', 'provisioning', 'starting', 'queued']);
183
+ const dup = (existing.deployments ?? []).find(d =>
184
+ ACTIVE.has(d.status) && d.workload_type === 'endpoint' && d.image === COMFYUI_IMAGE
185
+ );
186
+ if (dup && !flags.yes) {
187
+ console.log(chalk.yellow('\n ⚠ ComfyUI is already running:\n'));
188
+ console.log(` ${chalk.cyan(dup.deployment_id)} $${(dup.cost_per_hour || 0).toFixed(2)}/hr`);
189
+ if (dup.endpoint_url) console.log(` URL: ${chalk.cyan(dup.endpoint_url)}`);
190
+ console.log();
191
+ console.log(chalk.dim(' Reuse: Use the URL above'));
192
+ console.log(chalk.dim(` Stop first: badgr down ${dup.deployment_id}`));
193
+ console.log(chalk.dim(' Launch new: add --yes to this command\n'));
194
+ process.exitCode = 1;
195
+ return;
196
+ }
197
+ } catch {}
198
+ }
199
+
200
+ function buildBody(tierOverride) {
201
+ return {
202
+ image: COMFYUI_IMAGE,
203
+ gpu,
204
+ gpu_count: 1,
205
+ ...(flags.region ? { region: flags.region.toUpperCase() } : {}),
206
+ max_price_per_hour: flags.maxPrice,
207
+ tier: tierOverride || effectiveTier,
208
+ env: envObj,
209
+ ...(flags.maxCost ? { max_cost_usd: flags.maxCost } : {}),
210
+ };
211
+ }
212
+
213
+ let dep;
214
+ try {
215
+ dep = await callWithFallback(
216
+ '/serve',
217
+ { apiKey: config.apiKey, baseUrl: config.baseUrl },
218
+ (tierOverride) => buildBody(tierOverride),
219
+ effectiveTier,
220
+ chalk,
221
+ { thing: 'ComfyUI endpoint', cmd: 'badgr comfyui run' },
222
+ );
223
+ } catch (err) {
224
+ if (err.isPaymentRequired) { console.error(chalk.yellow(err.message)); process.exitCode = 1; return; }
225
+ console.error(err.message);
226
+ process.exitCode = 1;
227
+ return;
228
+ }
229
+
230
+ addDeployment({
231
+ id: dep.deployment_id,
232
+ type: 'endpoint',
233
+ gpu: dep.gpu_type,
234
+ count: dep.gpu_count,
235
+ status: dep.status,
236
+ endpointUrl: dep.endpoint_url || dep.openai_base_url,
237
+ receiptId: dep.receipt_id,
238
+ createdAt: new Date().toISOString(),
239
+ costPerHour: dep.cost_per_hour || 0,
240
+ providerRoute: dep.provider ?? null,
241
+ tier: dep.tier ?? null,
242
+ });
243
+
244
+ const rcptId = dep.receipt_id || generateReceiptId();
245
+ addReceipt({
246
+ receiptId: rcptId,
247
+ action: 'badgr comfyui run',
248
+ deploymentId: dep.deployment_id,
249
+ gpu: dep.gpu_type,
250
+ providerRoute: dep.provider ?? null,
251
+ tier: dep.tier ?? null,
252
+ status: dep.status,
253
+ createdAt: new Date().toISOString(),
254
+ });
255
+
256
+ const endpointUrl = dep.endpoint_url || dep.openai_base_url;
257
+ if (!endpointUrl) {
258
+ console.error(chalk.red(`\n ✗ No endpoint URL returned. Run: badgr status ${dep.deployment_id}\n`));
259
+ updateReceipt(rcptId, { status: 'no_endpoint_url' });
260
+ process.exitCode = 1;
261
+ return;
262
+ }
263
+
264
+ let endpointReady = false;
265
+ if (flags.noWait) {
266
+ console.log(chalk.yellow('\n Skipped health check (--no-wait)\n'));
267
+ } else {
268
+ const result = await waitForComfyUI(endpointUrl, dep.deployment_id, config, chalk);
269
+ process.stdout.write('\n');
270
+
271
+ if (result.depFailed) {
272
+ console.error(formatCliError('HEALTH_CHECK_DEPLOY_FAILED', {
273
+ deploymentId: dep.deployment_id,
274
+ failReason: result.failReason,
275
+ }, chalk));
276
+ updateReceipt(rcptId, { status: 'failed', failReason: result.failReason });
277
+ process.exitCode = 1;
278
+ return;
279
+ }
280
+
281
+ endpointReady = result.ready;
282
+ if (!endpointReady) updateReceipt(rcptId, { status: 'health_check_timeout' });
283
+ }
284
+
285
+ if (flags.checkNodes && endpointReady) {
286
+ await validateComfyNodes(endpointUrl, flags.checkNodes, chalk);
287
+ }
288
+
289
+ updateReceipt(rcptId, {
290
+ status: endpointReady ? 'ready' : 'starting',
291
+ endpointUrl,
292
+ updatedAt: new Date().toISOString(),
293
+ });
294
+
295
+ if (endpointReady) {
296
+ console.log(chalk.green('\n ✓ ComfyUI ready\n'));
297
+ } else {
298
+ console.log(chalk.yellow('\n ⏳ ComfyUI still starting.\n'));
299
+ console.log(` ${chalk.bold('Continue watching:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
300
+ console.log(` ${chalk.bold('Stop billing now:')} ${chalk.dim(`badgr down ${dep.deployment_id}`)}`);
301
+ console.log();
302
+ }
303
+
304
+ const rate = dep.cost_per_hour || 0;
305
+ console.log(` ${chalk.bold('URL:')} ${chalk.cyan(endpointUrl)}`);
306
+ console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
307
+ if (rate > 0) console.log(` ${chalk.bold('Rate:')} $${rate.toFixed(2)}/hr`);
308
+ if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
309
+ console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
310
+ console.log(` ${chalk.bold('Logs:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
311
+ console.log(` ${chalk.bold('Stop billing:')} ${chalk.cyan(`badgr down ${dep.deployment_id}`)}`);
312
+ console.log();
313
+
314
+ if (rate > HIGH_RATE_THRESHOLD && !flags.maxCost) {
315
+ console.log(chalk.yellow(` Rate: $${rate.toFixed(2)}/hr — use --max-cost to cap total spend.\n`));
316
+ }
317
+ console.log(chalk.dim(' Billing continues until you run: ') + chalk.cyan(`badgr down ${dep.deployment_id}`));
318
+ }
@@ -1,12 +1,21 @@
1
1
  import { requireApiKey } from '../config.js';
2
2
  import { findDeployment, removeDeployment, addReceipt, generateReceiptId } from '../store.js';
3
- import { terminateDeployment } from '../api.js';
3
+ import { terminateDeployment, listDeployments } from '../api.js';
4
4
 
5
5
  export async function downCommand(config, args, chalk) {
6
+ const hasAll = args.includes('--all');
7
+ const hasYes = args.includes('--yes') || args.includes('-y');
8
+
9
+ if (hasAll) {
10
+ return _downAll(config, chalk, hasYes);
11
+ }
12
+
6
13
  const idOrName = args.find(a => !a.startsWith('--'));
7
14
 
8
15
  if (!idOrName) {
9
16
  console.error(chalk.red('Usage: badgr down <deployment-id|name>'));
17
+ console.error(chalk.dim(' badgr down --all stop everything'));
18
+ console.error(chalk.dim(' badgr down --all --yes stop everything without confirmation'));
10
19
  return;
11
20
  }
12
21
 
@@ -55,3 +64,80 @@ export async function downCommand(config, args, chalk) {
55
64
  if (finalCost > 0) console.log(` ${chalk.bold('Final cost:')} $${finalCost.toFixed(4)}`);
56
65
  console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}\n`);
57
66
  }
67
+
68
+ async function _downAll(config, chalk, skipConfirm) {
69
+ requireApiKey(config);
70
+
71
+ let result;
72
+ try {
73
+ result = await listDeployments(config);
74
+ } catch (err) {
75
+ console.error(chalk.red(`\n ✗ Could not fetch deployments: ${err.message}\n`));
76
+ return;
77
+ }
78
+
79
+ const ACTIVE = new Set(['running', 'provisioning', 'starting', 'queued']);
80
+ const active = (result.deployments ?? []).filter(d => ACTIVE.has(d.status));
81
+
82
+ if (active.length === 0) {
83
+ console.log(chalk.dim('\n Nothing running. Billing is $0/hr.\n'));
84
+ return;
85
+ }
86
+
87
+ const totalRate = active.reduce((s, d) => s + (d.cost_per_hour || 0), 0);
88
+ console.log(chalk.bold(`\n ${active.length} deployment(s) running — $${totalRate.toFixed(2)}/hr\n`));
89
+ for (const d of active) {
90
+ const rate = (d.cost_per_hour || 0).toFixed(2);
91
+ const label = d.model || d.image || d.workload_type || '';
92
+ console.log(` ${chalk.cyan(d.deployment_id)} ${d.gpu_type || ''} $${rate}/hr ${chalk.dim(label)}`);
93
+ }
94
+ console.log();
95
+
96
+ if (!skipConfirm) {
97
+ const { createInterface } = await import('readline');
98
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
99
+ const answer = await new Promise(resolve => {
100
+ rl.question(chalk.yellow(` Stop all ${active.length} deployment(s)? [y/N] `), resolve);
101
+ });
102
+ rl.close();
103
+ if (!answer.trim().match(/^y$/i)) {
104
+ console.log(chalk.dim('\n Aborted.\n'));
105
+ return;
106
+ }
107
+ console.log();
108
+ }
109
+
110
+ let stoppedCount = 0;
111
+ let totalCost = 0;
112
+
113
+ for (const dep of active) {
114
+ process.stdout.write(chalk.dim(` Stopping ${dep.deployment_id}...`));
115
+ try {
116
+ const stopped = await terminateDeployment(config, dep.deployment_id);
117
+ const stoppedAt = stopped.stopped_at ?? (Date.now() / 1000);
118
+ const startedAt = stopped.started_at ?? stoppedAt;
119
+ const cost = (stopped.cost_per_hour || 0) * (stoppedAt - startedAt) / 3600;
120
+ totalCost += cost;
121
+ process.stdout.write(chalk.green(' done\n'));
122
+ stoppedCount++;
123
+ removeDeployment(dep.deployment_id);
124
+ const rcptId = generateReceiptId();
125
+ addReceipt({
126
+ receiptId: rcptId,
127
+ action: 'badgr down --all',
128
+ deploymentId: dep.deployment_id,
129
+ gpu: dep.gpu_type,
130
+ runtimeSeconds: Math.round(stoppedAt - startedAt),
131
+ finalCost: cost,
132
+ status: 'terminated',
133
+ createdAt: new Date().toISOString(),
134
+ });
135
+ } catch (err) {
136
+ process.stdout.write(chalk.red(` ✗ ${err.message}\n`));
137
+ }
138
+ }
139
+
140
+ console.log(chalk.green(`\n✓ Stopped ${stoppedCount}/${active.length} deployment(s)`));
141
+ if (totalCost > 0) console.log(` Estimated total: $${totalCost.toFixed(4)}`);
142
+ console.log(chalk.green(' Billing ended\n'));
143
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * badgr embed <model> <input-url-or-file> [--max-cost N]
3
+ *
4
+ * Generates text embeddings on GPU using vLLM's embed task.
5
+ * Accepts a public URL, S3/GCS URI, or a local text file under 10 MB.
6
+ * Embeddings are written to stdout by the container and appear in logs.
7
+ *
8
+ * Examples:
9
+ * badgr embed BAAI/bge-large-en-v1.5 documents.txt --max-cost 2
10
+ * badgr embed BAAI/bge-large-en-v1.5 s3://bucket/corpus.jsonl
11
+ */
12
+ import { readFileSync, existsSync, statSync } from 'fs';
13
+ import { requireApiKey } from '../config.js';
14
+ import { addReceipt, generateReceiptId } from '../store.js';
15
+ import { normalizeTier, callWithFallback } from '../fallback.js';
16
+ import { monitorBatchJob, fmtRuntime } from '../batch.js';
17
+
18
+ const EMBED_IMAGE = 'vllm/vllm-openai:latest';
19
+ const DEFAULT_EMBED_MODEL = 'BAAI/bge-large-en-v1.5';
20
+ const DEFAULT_MAX_RUNTIME_MIN = 30;
21
+ const MAX_INLINE_B = 10 * 1024 * 1024; // 10 MB inline limit
22
+
23
+ export function parseEmbedArgs(args) {
24
+ const flags = {};
25
+ const positional = [];
26
+ let i = 0;
27
+ while (i < args.length) {
28
+ const a = args[i];
29
+ if (a === '--model') { flags.model = args[++i]; i++; continue; }
30
+ if (a === '--gpu') { flags.gpu = args[++i]; i++; continue; }
31
+ if (a === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
32
+ if (a === '--max-price') { flags.maxPrice = parseFloat(args[++i]); i++; continue; }
33
+ if (a === '--max-runtime') { flags.maxRuntime = parseFloat(args[++i]); i++; continue; }
34
+ if (a === '--tier') { flags.tier = args[++i]; i++; continue; }
35
+ if (a === '--region') { flags.region = args[++i]; i++; continue; }
36
+ if (a === '--batch-size') { flags.batchSize = parseInt(args[++i], 10); i++; continue; }
37
+ if (a === '--detach') { flags.detach = true; i++; continue; }
38
+ if (a === '--env') {
39
+ const kv = args[++i]; i++;
40
+ if (!flags.env) flags.env = [];
41
+ flags.env.push(kv);
42
+ continue;
43
+ }
44
+ positional.push(args[i++]);
45
+ }
46
+ // positional: [model, input] or [input] (model falls back to default or --model flag)
47
+ const model = positional.length >= 2 ? positional[0] : (flags.model || DEFAULT_EMBED_MODEL);
48
+ const input = positional.length >= 2 ? positional[1] : positional[0] || null;
49
+ return { model, input, flags };
50
+ }
51
+
52
+ function parseEnvFlag(envList) {
53
+ const obj = {};
54
+ for (const kv of (envList || [])) {
55
+ const idx = kv.indexOf('=');
56
+ if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
57
+ }
58
+ return obj;
59
+ }
60
+
61
+ function isUrl(s) {
62
+ return /^(https?|s3|gs|hf):\/\//i.test(s);
63
+ }
64
+
65
+ /**
66
+ * Resolve input to { inputUrl, inputB64, inputLabel }.
67
+ */
68
+ export function resolveEmbedInput(input) {
69
+ if (isUrl(input)) {
70
+ return { inputUrl: input, inputB64: null, inputLabel: input };
71
+ }
72
+ if (!existsSync(input)) {
73
+ return { error: `File not found: ${input}` };
74
+ }
75
+ const size = statSync(input).size;
76
+ if (size > MAX_INLINE_B) {
77
+ return {
78
+ error: `File too large for inline transfer (${(size / 1024 / 1024).toFixed(1)} MB > 10 MB limit).\n` +
79
+ ' Upload to S3 or a public URL and pass the URL instead.',
80
+ };
81
+ }
82
+ const inputB64 = readFileSync(input).toString('base64');
83
+ return { inputUrl: null, inputB64, inputLabel: input };
84
+ }
85
+
86
+ export async function embedCommand(config, args, chalk) {
87
+ const { model, input, flags } = parseEmbedArgs(args);
88
+
89
+ if (!input) {
90
+ console.error(chalk.red('\n Usage: badgr embed <model> <input-url-or-file>\n'));
91
+ console.error(chalk.dim(' Examples:'));
92
+ console.error(chalk.dim(' badgr embed BAAI/bge-large-en-v1.5 documents.txt --max-cost 2'));
93
+ console.error(chalk.dim(' badgr embed BAAI/bge-large-en-v1.5 s3://bucket/corpus.jsonl'));
94
+ console.error(chalk.dim(' badgr embed documents.txt (uses default model)\n'));
95
+ process.exitCode = 1;
96
+ return;
97
+ }
98
+
99
+ requireApiKey(config);
100
+
101
+ const resolved = resolveEmbedInput(input);
102
+ if (resolved.error) {
103
+ console.error(chalk.red(`\n ✗ ${resolved.error}\n`));
104
+ process.exitCode = 1;
105
+ return;
106
+ }
107
+
108
+ const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : 'AUTO';
109
+ const effectiveTier = normalizeTier(flags.tier);
110
+ const maxRuntimeMin = flags.maxRuntime ?? DEFAULT_MAX_RUNTIME_MIN;
111
+ const maxRuntimeMs = maxRuntimeMin * 60 * 1000;
112
+ const maxCost = flags.maxCost ?? null;
113
+
114
+ const envObj = {
115
+ ...parseEnvFlag(flags.env),
116
+ EMBED_MODEL: model,
117
+ VLLM_TASK: 'embed',
118
+ ...(resolved.inputUrl ? { EMBED_INPUT_URL: resolved.inputUrl } : {}),
119
+ ...(resolved.inputB64 ? { EMBED_INPUT_B64: resolved.inputB64 } : {}),
120
+ ...(flags.batchSize ? { EMBED_BATCH_SIZE: String(flags.batchSize) } : {}),
121
+ };
122
+
123
+ console.log(chalk.bold('\n📐 Embeddings\n'));
124
+ console.log(` ${chalk.bold('Input:')} ${resolved.inputLabel}`);
125
+ console.log(` ${chalk.bold('Model:')} ${model}`);
126
+ console.log(` ${chalk.bold('Image:')} ${EMBED_IMAGE}`);
127
+ console.log(` ${chalk.bold('GPU:')} ${gpu === 'AUTO' ? chalk.dim('auto (8+ GB VRAM)') : gpu}`);
128
+ console.log(` ${chalk.bold('Max runtime:')} ${maxRuntimeMin}min`);
129
+ if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
130
+ console.log();
131
+ process.stdout.write(chalk.dim(' Finding suitable capacity...\n'));
132
+
133
+ function buildBody(tierOverride) {
134
+ return {
135
+ image: EMBED_IMAGE,
136
+ command: ['sh', '-c', [
137
+ resolved.inputB64
138
+ ? 'echo "$EMBED_INPUT_B64" | base64 -d > /tmp/embed_input.txt && export INPUT_PATH=/tmp/embed_input.txt'
139
+ : 'export INPUT_PATH="$EMBED_INPUT_URL"',
140
+ 'python3 -c "' +
141
+ 'import json, sys; ' +
142
+ 'from vllm import LLM; ' +
143
+ 'llm = LLM(model=\\\"$EMBED_MODEL\\\", task=\\\"embed\\\"); ' +
144
+ 'lines = open(\\\"$INPUT_PATH\\\").read().splitlines(); ' +
145
+ 'outputs = llm.embed(lines); ' +
146
+ '[print(json.dumps({\\\"text\\\": l, \\\"embedding\\\": o.outputs.embedding})) for l, o in zip(lines, outputs)]' +
147
+ '"',
148
+ ].join(' && ')],
149
+ gpu,
150
+ gpu_count: 1,
151
+ ...(flags.region ? { region: flags.region.toUpperCase() } : {}),
152
+ max_price_per_hour: flags.maxPrice,
153
+ tier: tierOverride || effectiveTier,
154
+ env: envObj,
155
+ max_runtime_seconds: maxRuntimeMin * 60,
156
+ ...(maxCost ? { max_cost_usd: maxCost } : {}),
157
+ };
158
+ }
159
+
160
+ let dep;
161
+ try {
162
+ dep = await callWithFallback(
163
+ '/run',
164
+ { apiKey: config.apiKey, baseUrl: config.baseUrl },
165
+ (tierOverride) => buildBody(tierOverride),
166
+ effectiveTier,
167
+ chalk,
168
+ { thing: 'embeddings job', cmd: 'badgr embed' },
169
+ );
170
+ } catch (err) {
171
+ if (err.isPaymentRequired) { console.error(chalk.yellow(err.message)); process.exitCode = 1; return; }
172
+ console.error(err.message);
173
+ process.exitCode = 1;
174
+ return;
175
+ }
176
+
177
+ const rcptId = dep.receipt_id || generateReceiptId();
178
+ addReceipt({
179
+ receiptId: rcptId,
180
+ action: 'badgr embed',
181
+ deploymentId: dep.deployment_id,
182
+ gpu: dep.gpu_type,
183
+ providerRoute: dep.provider ?? null,
184
+ tier: dep.tier ?? null,
185
+ maxCost,
186
+ maxRuntime: maxRuntimeMin,
187
+ status: dep.status,
188
+ createdAt: new Date().toISOString(),
189
+ });
190
+
191
+ const rate = dep.cost_per_hour || 0;
192
+ console.log(chalk.dim(' Capacity found.\n'));
193
+ console.log(chalk.bold(` Job ID: ${chalk.cyan(dep.deployment_id)}`));
194
+ console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
195
+ if (rate > 0) console.log(` ${chalk.bold('Rate:')} $${rate.toFixed(2)}/hr`);
196
+ console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
197
+
198
+ if (flags.detach) {
199
+ console.log(`\n ${chalk.bold('Logs:')} ${chalk.dim(`badgr logs ${dep.deployment_id}`)}`);
200
+ console.log(chalk.dim(`\n Detached. View embeddings: badgr logs ${dep.deployment_id}\n`));
201
+ return;
202
+ }
203
+
204
+ const result = await monitorBatchJob(config, dep.deployment_id, rcptId, {
205
+ chalk,
206
+ maxRuntimeMs,
207
+ maxCost,
208
+ ratePerHour: rate,
209
+ });
210
+
211
+ console.log();
212
+ const finalCost = rate * (result.runtimeMs / 3_600_000);
213
+ console.log(` ${chalk.bold('Runtime:')} ${fmtRuntime(result.runtimeMs)}`);
214
+ if (rate > 0) console.log(` ${chalk.bold('Cost:')} $${finalCost.toFixed(4)}`);
215
+ console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
216
+
217
+ if (result.reason === 'complete' &&
218
+ (result.status === 'completed' || result.status === 'succeeded') &&
219
+ (result.exitCode === 0 || result.exitCode === null)) {
220
+ console.log(chalk.green('\n ✓ Embeddings complete\n'));
221
+ console.log(chalk.dim(' Embeddings (JSONL) appear above in the log output.'));
222
+ console.log(chalk.dim(` Full logs: badgr logs ${dep.deployment_id}\n`));
223
+ } else if (result.reason !== 'interrupted' &&
224
+ (result.status === 'failed' || (result.exitCode !== null && result.exitCode !== 0))) {
225
+ console.error(chalk.red('\n ✗ Embeddings job failed\n'));
226
+ console.error(chalk.dim(` Logs: badgr logs ${dep.deployment_id}\n`));
227
+ process.exitCode = result.exitCode ?? 1;
228
+ }
229
+ }