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.
- package/README.md +178 -240
- package/package.json +1 -1
- package/src/badgr.js +12 -0
- package/src/catalog.js +31 -0
- package/src/commands/comfyui.js +70 -56
- package/src/commands/detect.js +58 -0
- package/src/commands/receipts.js +39 -2
- package/src/commands/run.js +78 -3
- package/src/commands/serve.js +116 -5
- package/src/commands/train.js +22 -27
- package/src/detect.js +362 -0
- package/src/progress.js +160 -0
- package/src/store.js +11 -0
- package/tests/detect.test.js +191 -0
- package/tests/job-progress-poll.test.js +136 -0
- package/tests/productized-runners.test.js +7 -0
- package/tests/run-lifecycle.test.js +111 -1
- package/tests/serve-apps.test.js +189 -0
- package/tests/serve-lifecycle.test.js +21 -0
- package/tests/store.test.js +22 -1
- package/tests/template.test.js +4 -4
- package/tests/workload-templates.test.js +22 -0
package/src/commands/serve.js
CHANGED
|
@@ -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 {
|
|
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
|
|
package/src/commands/train.js
CHANGED
|
@@ -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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
if (
|
|
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.
|
|
270
|
-
|
|
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
|
+
}
|