badgr-cli 1.0.44 → 1.0.45

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.
@@ -4,7 +4,7 @@ import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../
4
4
  import { normalizeTier, callWithFallback } from '../fallback.js';
5
5
  import { formatCliError } from '../errors.js';
6
6
  import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides, BLESSED_VLLM_MODELS, isLikelyGatedModel } from '../catalog.js';
7
- import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock } from '../progress.js';
7
+ import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass } from '../progress.js';
8
8
 
9
9
  const LLAMA_CPP_IMAGE = 'michaelmanleyx/llama-cpp:server-cuda';
10
10
 
@@ -150,7 +150,12 @@ async function waitForEndpoint(deploymentId, config, timeoutMs = VLLM_SERVE_WAIT
150
150
  });
151
151
  if (['failed', 'terminated', 'error', 'stopped'].includes(dep.status)) {
152
152
  if (blockLines > 0) { _clearBlock(blockLines); blockLines = 0; }
153
- return { ready: false, timedOut: false, depFailed: true, failReason: dep.fix_hint || dep.error || dep.status };
153
+ return {
154
+ ready: false, timedOut: false, depFailed: true,
155
+ failReason: dep.fix_hint || dep.error || dep.status,
156
+ failureClass: dep.failure_class ?? null,
157
+ nextAction: dep.next_action ?? null,
158
+ };
154
159
  }
155
160
  if (dep.endpoint_ready) {
156
161
  if (blockLines > 0) { _clearBlock(blockLines); blockLines = 0; }
@@ -208,6 +213,101 @@ const _KNOWN_SERVE_FLAGS = new Set([
208
213
  '--persistent', '--yes', '-y', '--runtime', '--hf-repo', '--hf-file',
209
214
  ]);
210
215
 
216
+ function extractFlag(args, flagName) {
217
+ const rest = [];
218
+ let value = null;
219
+ for (let i = 0; i < args.length; i++) {
220
+ if (args[i] === flagName) { value = args[++i]; continue; }
221
+ rest.push(args[i]);
222
+ }
223
+ return { value, rest };
224
+ }
225
+
226
+ // Returns { id, endpointUrl } for a running vLLM endpoint matching modelId, or
227
+ // null. The id is needed so a caller that auto-launches a new one can print a
228
+ // precise `badgr down <id>` hint if something later fails.
229
+ async function findRunningVllmEndpoint(config, modelId) {
230
+ try {
231
+ const existing = await listDeployments(config);
232
+ const ACTIVE = new Set(['running', 'provisioning', 'starting', 'queued']);
233
+ const match = (existing.deployments ?? []).find(d =>
234
+ ACTIVE.has(d.status) && d.workload_type === 'endpoint' && d.model === modelId
235
+ );
236
+ if (!match) return null;
237
+ const endpointUrl = match.endpoint_url || match.openai_base_url || null;
238
+ return endpointUrl ? { id: match.deployment_id, endpointUrl } : null;
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ /**
245
+ * `badgr serve openwebui [--model <alias-or-id>] [--connect <url>] [flags]`
246
+ *
247
+ * Open WebUI serves a chat UI that connects to a model endpoint — usually
248
+ * vLLM. It is a separate served app from the model itself: this reuses a
249
+ * running vLLM endpoint for the model if one exists, or launches one via
250
+ * `serveCommand` first, then launches Open WebUI wired to it via
251
+ * OPENAI_API_BASE_URL/OPENAI_API_KEY. `--connect` skips vLLM discovery/launch
252
+ * entirely and points Open WebUI at any existing OpenAI-compatible endpoint —
253
+ * no model is ever touched in that mode.
254
+ */
255
+ async function serveOpenWebUICommand(config, args, chalk) {
256
+ requireApiKey(config);
257
+ const { value: connect, rest: afterConnect } = extractFlag(args, '--connect');
258
+ const { value: model, rest } = extractFlag(afterConnect, '--model');
259
+
260
+ let endpointUrl = connect || null;
261
+ let launchedVllmDepId = null; // set only if we launched a new vLLM (for cleanup hints)
262
+
263
+ if (!endpointUrl) {
264
+ const alias = model || 'qwen-7b';
265
+ const vllmAlias = BLESSED_VLLM_MODELS[alias];
266
+ const modelId = vllmAlias ? vllmAlias.model_id : alias;
267
+
268
+ const existing = await findRunningVllmEndpoint(config, modelId);
269
+ if (existing) {
270
+ endpointUrl = existing.endpointUrl;
271
+ console.log(chalk.dim(` Reusing running vLLM endpoint for ${modelId}: ${endpointUrl}\n`));
272
+ } else {
273
+ // Auto-launching vLLM reuses rest's --max-cost/--persistent, which also
274
+ // apply to Open WebUI below — that's two separately billed deployments,
275
+ // not a shared budget, so say so instead of a silent double spend.
276
+ const { value: webuiMaxCost } = extractFlag(rest, '--max-cost');
277
+ console.log(chalk.bold(`\n Open WebUI needs a model endpoint behind it — no running vLLM endpoint for ${modelId} found.\n`));
278
+ if (webuiMaxCost) {
279
+ console.log(chalk.yellow(` Launching vLLM first with its own --max-cost $${webuiMaxCost} cap (separate from Open WebUI's).`));
280
+ console.log(chalk.yellow(` Total possible spend across both deployments: ~$${(parseFloat(webuiMaxCost) * 2).toFixed(2)}.\n`));
281
+ }
282
+ await serveCommand(config, [alias, ...rest], chalk);
283
+ const launched = await findRunningVllmEndpoint(config, modelId);
284
+ if (!launched) {
285
+ console.error(chalk.red(`\n ✗ vLLM endpoint for ${modelId} did not come up — see output above.\n`));
286
+ console.error(chalk.dim(` Once it's ready, run: badgr serve openwebui --connect <endpoint-url>\n`));
287
+ process.exitCode = 1;
288
+ return;
289
+ }
290
+ endpointUrl = launched.endpointUrl;
291
+ launchedVllmDepId = launched.id;
292
+ }
293
+ }
294
+
295
+ const overrides = parseTemplateOverrides(rest);
296
+ // User-supplied --env intentionally wins over the auto-wired connection env.
297
+ overrides.env = {
298
+ OPENAI_API_BASE_URL: endpointUrl,
299
+ OPENAI_API_KEY: config.apiKey || 'sk-local',
300
+ ...overrides.env,
301
+ };
302
+ const flags = buildTemplateFlags(TEMPLATE_MAP.openwebui, overrides);
303
+ await serveCommand(config, flags, chalk);
304
+
305
+ if (process.exitCode && launchedVllmDepId) {
306
+ console.error(chalk.yellow(`\n Open WebUI failed to start. The vLLM endpoint it was connecting to is still running and billing separately.`));
307
+ console.error(chalk.dim(` Stop it with: badgr down ${launchedVllmDepId}\n`));
308
+ }
309
+ }
310
+
211
311
  export async function serveCommand(config, args, chalk) {
212
312
  // `badgr serve template <name> [flags]` — expand template defaults then re-dispatch
213
313
  if (args[0] === 'template') {
@@ -230,6 +330,12 @@ export async function serveCommand(config, args, chalk) {
230
330
  return serveCommand(config, expandedArgs, chalk);
231
331
  }
232
332
 
333
+ // `badgr serve openwebui|open-webui [flags]` — a chat UI that connects to a
334
+ // model endpoint (usually vLLM). See serveOpenWebUICommand.
335
+ if (args[0] && ['openwebui', 'open-webui'].includes(args[0].toLowerCase())) {
336
+ return serveOpenWebUICommand(config, args.slice(1), chalk);
337
+ }
338
+
233
339
  if (args.includes('--list-aliases')) {
234
340
  console.log(chalk.bold('\nTested model routes:\n'));
235
341
  for (const alias of Object.keys(BLESSED_VLLM_MODELS)) {
@@ -536,11 +642,12 @@ export async function serveCommand(config, args, chalk) {
536
642
 
537
643
  // Shared reporting for "deployment failed to start" — hit from both the
538
644
  // pre-poll status check and the waitForEndpoint poll loop below.
539
- function reportDeployFailure(failReason) {
645
+ function reportDeployFailure(failReason, failureInfo = {}) {
540
646
  console.error(formatCliError('HEALTH_CHECK_DEPLOY_FAILED', {
541
647
  deploymentId: dep.deployment_id,
542
648
  failReason,
543
649
  }, chalk));
650
+ _printFailureClass(chalk, { failure_class: failureInfo.failureClass, next_action: failureInfo.nextAction });
544
651
  if (gatedHintNeeded) {
545
652
  const rerun = args.join(' ');
546
653
  const retryCmd = rerun.includes('HF_TOKEN=') ? rerun : `${rerun} --env HF_TOKEN=$HF_TOKEN`;
@@ -578,7 +685,9 @@ export async function serveCommand(config, args, chalk) {
578
685
  timeoutMs: 10_000,
579
686
  });
580
687
  if (['failed', 'terminated', 'error'].includes(latest.status)) {
581
- reportDeployFailure(latest.error || latest.status);
688
+ reportDeployFailure(latest.error || latest.status, {
689
+ failureClass: latest.failure_class ?? null, nextAction: latest.next_action ?? null,
690
+ });
582
691
  return;
583
692
  }
584
693
  } catch {
@@ -592,7 +701,9 @@ export async function serveCommand(config, args, chalk) {
592
701
  );
593
702
 
594
703
  if (healthResult.depFailed) {
595
- reportDeployFailure(healthResult.failReason);
704
+ reportDeployFailure(healthResult.failReason, {
705
+ failureClass: healthResult.failureClass, nextAction: healthResult.nextAction,
706
+ });
596
707
  return;
597
708
  }
598
709
 
@@ -10,6 +10,7 @@ import { requireApiKey } from '../config.js';
10
10
  import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
11
11
  import { normalizeTier, callWithFallback } from '../fallback.js';
12
12
  import { monitorBatchJob, fmtRuntime } from '../batch.js';
13
+ import { pollJobUntilTerminal, renderJobClosingBlock } from '../progress.js';
13
14
 
14
15
  const MAX_CONFIG_B = 512 * 1024; // 512 KB config limit
15
16
 
@@ -239,35 +240,29 @@ export async function trainLoraCommand(config, args, chalk) {
239
240
  console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
240
241
  console.log(chalk.dim('\n Polling for completion — Ctrl+C to detach (GPU keeps running)\n'));
241
242
 
242
- // Poll until complete
243
- const startMs = Date.now();
244
243
  const maxMs = maxRuntime * 60 * 1000;
245
- while (Date.now() - startMs < maxMs) {
246
- await new Promise(r => setTimeout(r, 15_000));
247
- let detail;
248
- try {
249
- detail = await callApi(`/jobs/${job.job_id}`, {
250
- apiKey: config.apiKey,
251
- baseUrl: config.baseUrl,
252
- });
253
- } catch { continue; }
254
- process.stdout.write(`\r Status: ${detail.status} elapsed: ${Math.floor((Date.now() - startMs) / 1000)}s `);
255
- if (detail.status === 'completed') {
256
- const out = detail.output || {};
257
- console.log(chalk.green('\n\n ✓ Training complete\n'));
258
- if (out.adapter_url) console.log(` ${chalk.bold('Adapter:')} ${out.adapter_url}`);
259
- if (out.checkpoint_url) console.log(` ${chalk.bold('Checkpoint:')} ${out.checkpoint_url}`);
260
- console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}\n`);
261
- return;
262
- }
263
- if (detail.status === 'failed') {
264
- console.error(chalk.red(`\n\n ✗ Training failed: ${detail.error_code || ''} — ${detail.error_message || ''}\n`));
265
- process.exitCode = 1;
266
- return;
267
- }
244
+ const { outcome, detail } = await pollJobUntilTerminal(callApi, config, job.job_id, { chalk, maxMs });
245
+
246
+ if (outcome === 'polling_failed') {
247
+ console.error(chalk.red(`\n\n ✗ Lost contact with Badgr — could not confirm job status.\n`));
248
+ console.error(chalk.dim(` badgr status\n badgr logs ${job.job_id}\n`));
249
+ process.exitCode = 1;
250
+ return;
251
+ }
252
+ if (outcome === 'timed_out') {
253
+ console.error(chalk.yellow('\n\n Training still running detached. Check status:\n'));
254
+ console.error(chalk.dim(` badgr status\n`));
255
+ return;
256
+ }
257
+
258
+ if (outcome === 'completed') {
259
+ const out = detail.output || {};
260
+ if (out.adapter_url) console.log(`\n ${chalk.bold('Adapter:')} ${out.adapter_url}`);
261
+ if (out.checkpoint_url) console.log(` ${chalk.bold('Checkpoint:')} ${out.checkpoint_url}`);
262
+ if (out.warning) console.log(chalk.yellow(` ${out.warning}`));
268
263
  }
269
- console.error(chalk.yellow('\n\n Training still running — detached. Check status:\n'));
270
- console.error(chalk.dim(` badgr status\n`));
264
+ console.log(renderJobClosingBlock(chalk, detail, rcptId));
265
+ if (outcome === 'failed') process.exitCode = 1;
271
266
  }
272
267
 
273
268
  export async function trainCommand(config, args, chalk) {
package/src/detect.js ADDED
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Workload detection — infer how to run an arbitrary GPU project from its
3
+ * files, without asking the user to describe their infrastructure.
4
+ *
5
+ * Domain-agnostic by design: no code path here is specific to any one
6
+ * framework (ComfyUI, vLLM, RunPod, etc.) or provider. Detection works off
7
+ * generic, corroborating file/content signals — entry-point filenames,
8
+ * dependency manifests, port/health conventions, model-reference patterns —
9
+ * so the same engine covers custom scripts, endpoints, batch jobs, and
10
+ * training repos alike. Framework-specific signals are just additional
11
+ * evidence fed into the same generic scoring, never a special-cased branch.
12
+ *
13
+ * Pure and local: reads files under the target directory only, makes no
14
+ * network calls, and never reveals provider/routing details — that's the
15
+ * caller's job (see commands/detect.js and commands/run.js).
16
+ */
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+
20
+ // Mirrors _ZIP_EXCLUDES in commands/run.js — directories we never walk into.
21
+ const SKIP_DIRS = new Set([
22
+ '.git', 'node_modules', '__pycache__', '.venv', 'venv', 'env',
23
+ 'dist', 'build', '.next', '.nuxt', 'coverage', '.pytest_cache',
24
+ '.mypy_cache', '.ruff_cache', '.DS_Store', '.idea', '.vscode',
25
+ ]);
26
+
27
+ const MAX_READ_BYTES = 200_000; // don't slurp huge files for signal-scanning
28
+ const MAX_WALK_ENTRIES = 5000; // safety bound on very large trees
29
+
30
+ function safeReadText(absPath) {
31
+ try {
32
+ const stat = fs.statSync(absPath);
33
+ if (!stat.isFile() || stat.size > 5_000_000) return '';
34
+ const fd = fs.openSync(absPath, 'r');
35
+ const len = Math.min(stat.size, MAX_READ_BYTES);
36
+ const buf = Buffer.alloc(len);
37
+ fs.readSync(fd, buf, 0, len, 0);
38
+ fs.closeSync(fd);
39
+ return buf.toString('utf8');
40
+ } catch {
41
+ return '';
42
+ }
43
+ }
44
+
45
+ function walk(dir, files = [], depth = 0) {
46
+ if (files.length >= MAX_WALK_ENTRIES || depth > 8) return files;
47
+ let entries;
48
+ try {
49
+ entries = fs.readdirSync(dir, { withFileTypes: true });
50
+ } catch {
51
+ return files;
52
+ }
53
+ for (const entry of entries) {
54
+ if (files.length >= MAX_WALK_ENTRIES) break;
55
+ if (entry.name.startsWith('.') && !['.env.example'].includes(entry.name)) {
56
+ if (entry.isDirectory()) continue;
57
+ }
58
+ const abs = path.join(dir, entry.name);
59
+ if (entry.isDirectory()) {
60
+ if (SKIP_DIRS.has(entry.name)) continue;
61
+ walk(abs, files, depth + 1);
62
+ } else {
63
+ files.push(abs);
64
+ }
65
+ }
66
+ return files;
67
+ }
68
+
69
+ // ── Signal catalogs (generic — ordered by how strongly they imply an entry point) ──
70
+
71
+ const ENTRYPOINT_CANDIDATES = [
72
+ 'main.py', 'app.py', 'server.py', 'run.py', 'predict.py', 'inference.py',
73
+ 'handler.py', 'rp_handler.py', 'worker.py', 'train.py', 'finetune.py',
74
+ 'generate.py', 'transcribe.py', 'index.js', 'server.js', 'main.js',
75
+ ];
76
+
77
+ const REQUIREMENTS_FILES = [
78
+ 'requirements.txt', 'pyproject.toml', 'environment.yml', 'environment.yaml',
79
+ 'Pipfile', 'package.json', 'setup.py', 'poetry.lock',
80
+ ];
81
+
82
+ const MODEL_DIR_NAMES = ['models', 'checkpoints', 'ckpt', 'weights', 'model'];
83
+ const INPUT_DIR_NAMES = ['input', 'inputs', 'data', 'dataset', 'datasets'];
84
+ const OUTPUT_DIR_NAMES = ['output', 'outputs', 'out', 'results', 'generated'];
85
+ const CHECKPOINT_DIR_NAMES = ['checkpoints', 'checkpoint', 'ckpt', 'ckpts'];
86
+
87
+ const HF_REPO_RE = /\b[a-zA-Z0-9][\w.-]{1,60}\/[a-zA-Z0-9][\w.-]{1,80}\b/g;
88
+ const WEIGHT_FILE_RE = /[\w.-]+\.(safetensors|ckpt|gguf|bin|pt|pth|onnx)\b/gi;
89
+ const PARAM_SIZE_RE = /\b(\d{1,3})\s*[bB]\b/g;
90
+
91
+ // ── File-content sniffers ────────────────────────────────────────────────
92
+
93
+ function detectPorts(files) {
94
+ const ports = new Set();
95
+ for (const f of files) {
96
+ const base = path.basename(f);
97
+ const text = base === 'Dockerfile' || /\.(py|js|ts|toml|ya?ml)$/.test(base)
98
+ ? safeReadText(f) : '';
99
+ if (!text) continue;
100
+ for (const m of text.matchAll(/\bEXPOSE\s+(\d{2,5})/gi)) ports.add(Number(m[1]));
101
+ for (const m of text.matchAll(/\bport\s*[=:]\s*(\d{2,5})\b/gi)) ports.add(Number(m[1]));
102
+ for (const m of text.matchAll(/--port[= ](\d{2,5})/g)) ports.add(Number(m[1]));
103
+ }
104
+ return [...ports];
105
+ }
106
+
107
+ function detectModelRefs(files) {
108
+ const refs = new Set();
109
+ for (const f of files) {
110
+ const base = path.basename(f);
111
+ if (!/\.(py|json|ya?ml|toml|txt|md)$/.test(base)) continue;
112
+ const text = safeReadText(f);
113
+ if (!text) continue;
114
+ for (const m of text.matchAll(WEIGHT_FILE_RE)) refs.add(m[0]);
115
+ for (const m of text.matchAll(/\b(base_model|model_name|model_id|MODEL_NAME|--model)\s*[:=]\s*["']?([\w./-]+)["']?/g)) {
116
+ refs.add(m[2]);
117
+ }
118
+ // Hugging Face "org/name" style refs — only keep ones that look plausible
119
+ // (avoid matching file paths like src/foo by requiring at least one dot-free segment
120
+ // and no leading './').
121
+ for (const m of text.matchAll(HF_REPO_RE)) {
122
+ const val = m[0];
123
+ if (val.startsWith('.') || val.includes('://') || /\.(py|js|ts|json|ya?ml|txt|md|toml)$/.test(val)) continue;
124
+ if (/^(https?|import|from|src|lib|utils|tests?|scripts?|docs?)\//.test(val)) continue;
125
+ if (val.split('/').length !== 2) continue;
126
+ refs.add(val);
127
+ }
128
+ }
129
+ return [...refs].slice(0, 20);
130
+ }
131
+
132
+ function listDirsMatching(rootFiles, rootDir, names) {
133
+ const found = new Set();
134
+ for (const name of names) {
135
+ const abs = path.join(rootDir, name);
136
+ if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) found.add(name + '/');
137
+ }
138
+ return [...found];
139
+ }
140
+
141
+ function estimateVramFromModelRefs(refs, workloadType) {
142
+ let maxB = 0;
143
+ for (const ref of refs) {
144
+ for (const m of ref.matchAll(PARAM_SIZE_RE)) {
145
+ const n = parseInt(m[1], 10);
146
+ if (n > maxB && n <= 700) maxB = n;
147
+ }
148
+ }
149
+ if (maxB > 0) {
150
+ if (maxB <= 3) return '8+ GB';
151
+ if (maxB <= 8) return '16+ GB';
152
+ if (maxB <= 14) return '24+ GB';
153
+ if (maxB <= 34) return '40+ GB';
154
+ return '80+ GB';
155
+ }
156
+ const defaults = {
157
+ training: '40+ GB',
158
+ image_gen: '16+ GB',
159
+ endpoint: '24+ GB',
160
+ batch: '16+ GB',
161
+ custom: '16+ GB',
162
+ };
163
+ return defaults[workloadType] || '16+ GB';
164
+ }
165
+
166
+ function estimateRuntimeMinutes(workloadType) {
167
+ if (workloadType === 'endpoint') return null; // runs until stopped, not a fixed job
168
+ const defaults = {
169
+ training: 120,
170
+ image_gen: 20,
171
+ batch: 30,
172
+ custom: 30,
173
+ };
174
+ return defaults[workloadType] ?? 30;
175
+ }
176
+
177
+ // ── Entry-point + workload-type inference ────────────────────────────────
178
+
179
+ function pickEntrypoint(rootDir, allFiles) {
180
+ const relTop = allFiles
181
+ .filter(f => path.dirname(f) === rootDir)
182
+ .map(f => path.basename(f));
183
+
184
+ for (const candidate of ENTRYPOINT_CANDIDATES) {
185
+ if (relTop.includes(candidate)) {
186
+ return { file: candidate, confidenceBoost: 2 };
187
+ }
188
+ }
189
+ // Fall back to *any* single top-level .py/.js file if there's exactly one —
190
+ // common for "plain script" projects with no conventional entry-point name.
191
+ const topScripts = relTop.filter(f => /\.(py|js)$/.test(f));
192
+ if (topScripts.length === 1) {
193
+ return { file: topScripts[0], confidenceBoost: 1 };
194
+ }
195
+ return null;
196
+ }
197
+
198
+ function classifyWorkload({ entrypointText, hasWorkflowJson, hasDockerfile, hasTrainSignal, hasServeSignal, hasHandlerSignal, ports }) {
199
+ const signals = [];
200
+ let type = 'custom';
201
+ let score = 0;
202
+
203
+ if (hasHandlerSignal) {
204
+ signals.push('serverless-style handler function detected');
205
+ type = 'endpoint';
206
+ score += 2;
207
+ }
208
+ if (hasServeSignal || ports.length > 0) {
209
+ signals.push(ports.length > 0 ? `listens on port ${ports[0]}` : 'starts an HTTP server');
210
+ type = 'endpoint';
211
+ score += 2;
212
+ }
213
+ if (hasTrainSignal) {
214
+ signals.push('training/fine-tuning loop detected');
215
+ type = 'training';
216
+ score += 2;
217
+ }
218
+ if (hasWorkflowJson) {
219
+ signals.push('image-generation workflow file detected');
220
+ if (type === 'custom') type = 'image_gen';
221
+ score += 2;
222
+ }
223
+ if (hasDockerfile) {
224
+ signals.push('Dockerfile present');
225
+ score += 1;
226
+ }
227
+ if (/for\s+\w+\s+in\s+.*(os\.listdir|glob\(|Path\(.*\)\.iterdir)/.test(entrypointText || '')) {
228
+ signals.push('iterates over an input directory');
229
+ if (type === 'custom') type = 'batch';
230
+ score += 1;
231
+ }
232
+
233
+ return { type, score, signals };
234
+ }
235
+
236
+ /**
237
+ * Detect the shape of a GPU workload from a local project directory.
238
+ * Returns a plain object describing what was found — never throws for a
239
+ * missing/empty directory, so callers can always render a report.
240
+ */
241
+ export function detectWorkload(dirPath) {
242
+ const rootDir = path.resolve(dirPath);
243
+ if (!fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) {
244
+ return {
245
+ rootDir,
246
+ workloadType: 'custom',
247
+ confidence: 'low',
248
+ command: null,
249
+ image: null,
250
+ dockerfile: null,
251
+ requirements: [],
252
+ models: [],
253
+ inputs: [],
254
+ outputs: [],
255
+ checkpoints: [],
256
+ ports: [],
257
+ healthPath: null,
258
+ vram: '16+ GB',
259
+ runtimeEstimateMinutes: 30,
260
+ cacheableAssets: [],
261
+ signals: [],
262
+ error: 'directory not found',
263
+ };
264
+ }
265
+
266
+ const allFiles = walk(rootDir);
267
+
268
+ const dockerfilePath = allFiles.find(f => path.basename(f) === 'Dockerfile' && path.dirname(f) === rootDir);
269
+ const dockerfileText = dockerfilePath ? safeReadText(dockerfilePath) : '';
270
+
271
+ const requirements = REQUIREMENTS_FILES.filter(name =>
272
+ allFiles.some(f => path.basename(f) === name && path.dirname(f) === rootDir));
273
+
274
+ const workflowJson = allFiles.find(f =>
275
+ /workflow.*\.json$/i.test(path.basename(f)) && path.dirname(f) === rootDir);
276
+
277
+ const entry = pickEntrypoint(rootDir, allFiles);
278
+ const entrypointText = entry ? safeReadText(path.join(rootDir, entry.file)) : '';
279
+ const combinedText = entrypointText + '\n' + dockerfileText;
280
+
281
+ const hasServeSignal = /(uvicorn|fastapi|flask|app\.run\(|vllm\s+serve|VLLM|litserve|gradio|\.listen\()/i.test(combinedText);
282
+ const hasHandlerSignal = /(runpod\.serverless\.start|def\s+handler\s*\(|exports\.handler\s*=)/i.test(combinedText);
283
+ const hasTrainSignal = /(\.backward\(\)|optimizer\.step\(|Trainer\(|axolotl|lora|qlora|peft|accelerate|kohya)/i.test(combinedText) ||
284
+ (entry && /train|finetune/i.test(entry.file));
285
+ const ports = detectPorts([dockerfilePath, entry ? path.join(rootDir, entry.file) : null].filter(Boolean));
286
+
287
+ const { type: workloadType, score, signals } = classifyWorkload({
288
+ entrypointText: combinedText,
289
+ hasWorkflowJson: Boolean(workflowJson),
290
+ hasDockerfile: Boolean(dockerfilePath),
291
+ hasTrainSignal,
292
+ hasServeSignal,
293
+ hasHandlerSignal,
294
+ ports,
295
+ });
296
+
297
+ const models = detectModelRefs([
298
+ ...(entry ? [path.join(rootDir, entry.file)] : []),
299
+ ...(workflowJson ? [workflowJson] : []),
300
+ ...allFiles.filter(f => /\.ya?ml$/i.test(f) && path.dirname(f) === rootDir),
301
+ ]);
302
+
303
+ const inputs = listDirsMatching(allFiles, rootDir, INPUT_DIR_NAMES);
304
+ const outputs = listDirsMatching(allFiles, rootDir, OUTPUT_DIR_NAMES);
305
+ const checkpoints = listDirsMatching(allFiles, rootDir, CHECKPOINT_DIR_NAMES);
306
+ const modelDirs = listDirsMatching(allFiles, rootDir, MODEL_DIR_NAMES);
307
+
308
+ let command = null;
309
+ if (entry) {
310
+ if (/\.py$/.test(entry.file)) command = `python ${entry.file}`;
311
+ else if (/\.js$/.test(entry.file)) command = `node ${entry.file}`;
312
+ } else if (workflowJson) {
313
+ command = `python main.py --workflow ${path.basename(workflowJson)}`;
314
+ }
315
+
316
+ let confidenceScore = score + (entry ? entry.confidenceBoost : 0) + (requirements.length > 0 ? 1 : 0);
317
+ let confidence = 'low';
318
+ if (confidenceScore >= 4) confidence = 'high';
319
+ else if (confidenceScore >= 2) confidence = 'medium';
320
+
321
+ if (entry) signals.push(`entry point: ${entry.file}`);
322
+ if (requirements.length) signals.push(`dependency manifest: ${requirements.join(', ')}`);
323
+ if (!entry && !workflowJson) signals.push('no recognizable entry point — command must be supplied');
324
+
325
+ const healthPath = hasHandlerSignal ? null : (hasServeSignal ? '/health' : null);
326
+
327
+ const cacheableAssets = [
328
+ ...modelDirs,
329
+ ...(requirements.length ? requirements : []),
330
+ ];
331
+
332
+ return {
333
+ rootDir,
334
+ workloadType,
335
+ confidence,
336
+ command,
337
+ image: dockerfilePath ? null : undefined, // Dockerfile present → image is built from it, not picked
338
+ dockerfile: dockerfilePath ? path.relative(rootDir, dockerfilePath) : null,
339
+ requirements,
340
+ models,
341
+ inputs,
342
+ outputs,
343
+ checkpoints,
344
+ ports,
345
+ healthPath,
346
+ vram: estimateVramFromModelRefs(models, workloadType),
347
+ runtimeEstimateMinutes: estimateRuntimeMinutes(workloadType),
348
+ cacheableAssets,
349
+ signals,
350
+ };
351
+ }
352
+
353
+ /** Human label for a workload type — used by both `detect` and `run` reports. */
354
+ export function workloadTypeLabel(type) {
355
+ return {
356
+ endpoint: 'endpoint',
357
+ training: 'training',
358
+ image_gen: 'batch (image generation)',
359
+ batch: 'batch',
360
+ custom: 'custom GPU job',
361
+ }[type] || 'custom GPU job';
362
+ }