badgr-cli 1.0.48 → 1.1.0
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 +38 -0
- package/package.json +1 -1
- package/src/api.js +16 -2
- package/src/artifactDownload.js +55 -0
- package/src/badgr.js +104 -0
- package/src/batch.js +22 -4
- package/src/browser.js +23 -0
- package/src/commands/artifacts.js +75 -0
- package/src/commands/batch.js +221 -28
- package/src/commands/billing.js +1 -12
- package/src/commands/capacity.js +9 -4
- package/src/commands/comfyui.js +3 -3
- package/src/commands/connect.js +83 -0
- package/src/commands/doctor.js +127 -0
- package/src/commands/down.js +29 -6
- package/src/commands/launch.js +431 -0
- package/src/commands/pull.js +137 -0
- package/src/commands/run.js +253 -37
- package/src/commands/sbatch.js +232 -0
- package/src/commands/serve.js +3 -3
- package/src/commands/status.js +12 -4
- package/src/commands/task.js +25 -0
- package/src/commands/test-run.js +4 -2
- package/src/credentials.js +65 -0
- package/src/fallback.js +7 -2
- package/src/fanout.js +70 -0
- package/src/gpuDoctor/diskInfo.js +42 -0
- package/src/gpuDoctor/doctor.js +451 -0
- package/src/gpuDoctor/gpuInfo.js +70 -0
- package/src/gpuDoctor/healthCheck.js +63 -0
- package/src/gpuDoctor/logClassifier.js +138 -0
- package/src/gpuDoctor/modelFit.js +107 -0
- package/src/gpuDoctor/probeCache.js +38 -0
- package/src/gpuDoctor/redact.js +29 -0
- package/src/gpuDoctor/torchInfo.js +61 -0
- package/src/gpuDoctor/workflowDoctor.js +96 -0
- package/src/onboarding.js +124 -0
- package/src/slurm.js +193 -0
- package/src/spec.js +59 -2
- package/src/store.js +16 -0
- package/tests/agent-images.test.js +17 -0
- package/tests/artifactDownload.test.js +113 -0
- package/tests/artifacts.test.js +168 -0
- package/tests/batch.test.js +312 -0
- package/tests/browser.test.js +51 -0
- package/tests/capacity.test.js +68 -0
- package/tests/commands.test.js +44 -0
- package/tests/connect.test.js +83 -0
- package/tests/down.test.js +23 -1
- package/tests/fallback-timeout.test.js +41 -0
- package/tests/fanout.test.js +124 -0
- package/tests/gpu-doctor-classifiers.test.js +402 -0
- package/tests/gpu-doctor-doctor.test.js +304 -0
- package/tests/gpu-doctor-probe-cache.test.js +110 -0
- package/tests/gpu-doctor-probes.test.js +257 -0
- package/tests/launch-command-argv.test.js +93 -0
- package/tests/launch-readiness.test.js +1 -0
- package/tests/launch.test.js +440 -0
- package/tests/onboarding.test.js +134 -0
- package/tests/pull.test.js +266 -0
- package/tests/run-lifecycle.test.js +405 -6
- package/tests/sbatch.test.js +190 -0
- package/tests/secrets.test.js +16 -0
- package/tests/slurm.test.js +77 -0
- package/tests/spec.test.js +59 -1
- package/tests/status.test.js +73 -0
- package/tests/task.test.js +109 -0
- package/tests/template.test.js +7 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { detectGpus } from './gpuInfo.js';
|
|
3
|
+
import { detectTorch } from './torchInfo.js';
|
|
4
|
+
import { detectDisk } from './diskInfo.js';
|
|
5
|
+
import { estimateModelFit } from './modelFit.js';
|
|
6
|
+
import { classifyLog } from './logClassifier.js';
|
|
7
|
+
import { diagnoseWorkflow, parseWorkflowFile } from './workflowDoctor.js';
|
|
8
|
+
import { checkHealth } from './healthCheck.js';
|
|
9
|
+
import { redactLine } from './redact.js';
|
|
10
|
+
import { findCheapest, findByCanonical, GPU_CATALOG } from '../router.js';
|
|
11
|
+
import { readProbeCache, writeProbeCache } from './probeCache.js';
|
|
12
|
+
|
|
13
|
+
function shellQuote(value) {
|
|
14
|
+
return /^[a-z0-9_./:@-]+$/i.test(value) ? value : JSON.stringify(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function resolveNamedGpuVramGb(gpuName) {
|
|
18
|
+
if (!gpuName) return undefined;
|
|
19
|
+
const canonical = gpuName.toUpperCase().replace(/[\s-]+/g, '_');
|
|
20
|
+
const byCanonical = findByCanonical(canonical);
|
|
21
|
+
if (byCanonical) return byCanonical.vramGb;
|
|
22
|
+
const byName = GPU_CATALOG.find((g) => g.name.toUpperCase().replace(/[\s-]+/g, '_') === canonical);
|
|
23
|
+
return byName ? byName.vramGb : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function suggestGpuCanonical(minVramGb) {
|
|
27
|
+
const match = findCheapest({ minVramGb: minVramGb ?? 0, tag: 'inference' });
|
|
28
|
+
return match ? match.canonical : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildServeCommand(model, minVramGb, gpuCount = 1) {
|
|
32
|
+
const gpu = suggestGpuCanonical(minVramGb);
|
|
33
|
+
const parts = ['badgr serve', shellQuote(model || 'your-model')];
|
|
34
|
+
if (gpu) parts.push(`--gpu ${gpu}`);
|
|
35
|
+
if (gpuCount > 1) parts.push(`--count ${gpuCount}`);
|
|
36
|
+
parts.push('--max-cost 10');
|
|
37
|
+
return parts.join(' ');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read-only, local system probe. Never installs, restarts, kills, or
|
|
42
|
+
* deletes anything — every check here is inspection only.
|
|
43
|
+
*
|
|
44
|
+
* The real nvidia-smi / python-torch probes are cached for a few seconds
|
|
45
|
+
* (see probeCache.js) so repeated `badgr doctor` runs don't re-pay that
|
|
46
|
+
* cost. Caching only applies to the real probes: a caller-injected
|
|
47
|
+
* deps.detectGpus/detectTorch (e.g. in tests) always runs directly and is
|
|
48
|
+
* never read from or written to the shared cache. Pass deps.noCache to
|
|
49
|
+
* force fresh probes (e.g. right after a driver change).
|
|
50
|
+
*/
|
|
51
|
+
export function collectEnvironment(deps = {}) {
|
|
52
|
+
const cacheOpts = { ttlMs: deps.cacheTtlMs, cachePath: deps.cachePath, fsImpl: deps.cacheFsImpl };
|
|
53
|
+
const useCache = !deps.noCache;
|
|
54
|
+
|
|
55
|
+
let gpu;
|
|
56
|
+
if (deps.detectGpus) {
|
|
57
|
+
gpu = deps.detectGpus(deps.execImpl);
|
|
58
|
+
} else {
|
|
59
|
+
gpu = useCache ? readProbeCache('gpu', cacheOpts) : undefined;
|
|
60
|
+
if (!gpu) {
|
|
61
|
+
gpu = detectGpus(deps.execImpl);
|
|
62
|
+
if (useCache) writeProbeCache('gpu', gpu, cacheOpts);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let torch;
|
|
67
|
+
if (deps.detectTorch) {
|
|
68
|
+
torch = deps.detectTorch(deps.execImpl);
|
|
69
|
+
} else {
|
|
70
|
+
torch = useCache ? readProbeCache('torch', cacheOpts) : undefined;
|
|
71
|
+
if (!torch) {
|
|
72
|
+
torch = detectTorch(deps.execImpl);
|
|
73
|
+
if (useCache) writeProbeCache('torch', torch, cacheOpts);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const disk = (deps.detectDisk || detectDisk)(deps.fsImpl);
|
|
78
|
+
return { gpu, torch, disk };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function localAvailableVramGb(env, gpuCount = 1) {
|
|
82
|
+
const gpus = env.gpu.gpus || [];
|
|
83
|
+
if (gpus.length === 0) return undefined;
|
|
84
|
+
if (gpuCount <= 1) {
|
|
85
|
+
return Math.max(...gpus.map((g) => g.vramFreeGb ?? g.vramTotalGb ?? 0));
|
|
86
|
+
}
|
|
87
|
+
// Tensor-parallel sizing: the group is bottlenecked by whichever of the
|
|
88
|
+
// requested GPUs has the least free VRAM.
|
|
89
|
+
const sorted = [...gpus].sort((a, b) => (b.vramFreeGb ?? b.vramTotalGb ?? 0) - (a.vramFreeGb ?? a.vramTotalGb ?? 0));
|
|
90
|
+
const usable = sorted.slice(0, gpuCount);
|
|
91
|
+
return Math.min(...usable.map((g) => g.vramFreeGb ?? g.vramTotalGb ?? 0));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function baselineVerdict(env) {
|
|
95
|
+
const evidence = [];
|
|
96
|
+
if (!env.gpu.available || env.gpu.gpus.length === 0) {
|
|
97
|
+
evidence.push('nvidia-smi: no NVIDIA GPU detected');
|
|
98
|
+
return {
|
|
99
|
+
verdict: 'no NVIDIA GPU detected',
|
|
100
|
+
verdictSlug: 'no_gpu_detected',
|
|
101
|
+
category: 'gpu_unavailable',
|
|
102
|
+
likelyCause: 'GPU unavailable to this process, or not passed into the container.',
|
|
103
|
+
evidence,
|
|
104
|
+
fixes: [
|
|
105
|
+
'Check that the host actually has an NVIDIA GPU',
|
|
106
|
+
'If running in Docker, pass --gpus all',
|
|
107
|
+
'Verify the NVIDIA Container Toolkit is installed',
|
|
108
|
+
'Or run on Badgr: badgr serve <model>',
|
|
109
|
+
],
|
|
110
|
+
badgrCommand: 'badgr serve <model> --max-cost 10',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const g of env.gpu.gpus) {
|
|
115
|
+
evidence.push(`GPU ${g.index}: ${g.name}, ${g.vramTotalGb}GB VRAM (free: ${g.vramFreeGb}GB)`);
|
|
116
|
+
}
|
|
117
|
+
if (env.gpu.driverVersion) evidence.push(`Driver version: ${env.gpu.driverVersion}`);
|
|
118
|
+
if (env.gpu.cudaVersion) evidence.push(`CUDA version (nvidia-smi): ${env.gpu.cudaVersion}`);
|
|
119
|
+
|
|
120
|
+
if (!env.torch.torchInstalled) {
|
|
121
|
+
evidence.push('PyTorch: not installed');
|
|
122
|
+
return {
|
|
123
|
+
verdict: 'GPU visible but PyTorch not installed',
|
|
124
|
+
verdictSlug: 'torch_not_installed',
|
|
125
|
+
category: 'cuda_pytorch_mismatch',
|
|
126
|
+
likelyCause: 'The GPU is visible to the system, but no PyTorch install was found to confirm it is usable for GPU workloads.',
|
|
127
|
+
evidence,
|
|
128
|
+
fixes: ['Install a CUDA-enabled torch build matching the driver', 'Verify the correct Python environment is active'],
|
|
129
|
+
badgrCommand: 'badgr serve <model> --max-cost 10',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!env.torch.cudaAvailable) {
|
|
134
|
+
evidence.push(`PyTorch installed (${env.torch.pythonBinary}); torch.cuda.is_available() = False`);
|
|
135
|
+
return {
|
|
136
|
+
verdict: 'CUDA visible to system but not to PyTorch',
|
|
137
|
+
verdictSlug: 'cuda_not_visible_to_pytorch',
|
|
138
|
+
category: 'cuda_pytorch_mismatch',
|
|
139
|
+
likelyCause: 'CPU-only torch wheel, a mismatched CUDA runtime, or the container is missing GPU access.',
|
|
140
|
+
evidence,
|
|
141
|
+
fixes: [
|
|
142
|
+
'Reinstall torch with a CUDA-enabled build matching the driver',
|
|
143
|
+
'If containerized, verify --gpus all / GPU passthrough is set',
|
|
144
|
+
'Check that CUDA_VISIBLE_DEVICES is not empty or misconfigured',
|
|
145
|
+
],
|
|
146
|
+
badgrCommand: 'badgr serve <model> --max-cost 10',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
evidence.push(`PyTorch CUDA OK: ${env.torch.deviceName || 'GPU'} via torch ${env.torch.cudaVersion || ''}`.trim());
|
|
151
|
+
return {
|
|
152
|
+
verdict: 'environment looks healthy',
|
|
153
|
+
verdictSlug: 'environment_ok',
|
|
154
|
+
category: 'environment_ok',
|
|
155
|
+
likelyCause: 'GPU is visible to both the system and PyTorch. No environment-level blocker detected.',
|
|
156
|
+
evidence,
|
|
157
|
+
fixes: [],
|
|
158
|
+
badgrCommand: null,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function diskEvidence(disk) {
|
|
163
|
+
const lines = [`Model cache: ${disk.cachePath} (${disk.cacheSource})`];
|
|
164
|
+
lines.push(`Cache path exists: ${disk.cacheExists ? 'yes' : 'no'}; writable: ${disk.writable ? 'yes' : 'no'}`);
|
|
165
|
+
if (disk.freeGb != null) lines.push(`Free disk: ${disk.freeGb}GB`);
|
|
166
|
+
return lines;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function modelVerdict(model, env, options) {
|
|
170
|
+
const gpuCount = Math.max(1, Math.round(options.gpuCount) || 1);
|
|
171
|
+
const estimate = estimateModelFit(model, { contextLen: options.contextLen, gpuCount });
|
|
172
|
+
const namedGpuVramGb = resolveNamedGpuVramGb(options.gpu);
|
|
173
|
+
const gpuNameUnrecognized = Boolean(options.gpu) && namedGpuVramGb === undefined;
|
|
174
|
+
const availableVramGb = options.vramGb ?? namedGpuVramGb ?? localAvailableVramGb(env, gpuCount);
|
|
175
|
+
const perGpuLabel = gpuCount > 1 ? ' per GPU' : '';
|
|
176
|
+
const evidence = [`Model estimate: ${estimate.perGpuVramMinGb ?? '?'}–${estimate.perGpuVramMaxGb ?? '?'}GB${perGpuLabel} at current settings (${estimate.sizeClass})`];
|
|
177
|
+
if (gpuCount > 1) {
|
|
178
|
+
evidence.push(`GPU count: ${gpuCount} (tensor-parallel), ${estimate.vramMinGb ?? '?'}–${estimate.vramMaxGb ?? '?'}GB total across all GPUs`);
|
|
179
|
+
const physicalGpuCount = (env.gpu.gpus || []).length;
|
|
180
|
+
if (physicalGpuCount > 0 && physicalGpuCount < gpuCount) {
|
|
181
|
+
evidence.push(`Only ${physicalGpuCount} local GPU(s) detected — fewer than the ${gpuCount} requested`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (gpuNameUnrecognized) evidence.push(`GPU name "${options.gpu}" not recognized in the catalog`);
|
|
185
|
+
if (availableVramGb !== undefined) evidence.push(`Available VRAM: ${availableVramGb}GB${perGpuLabel}`);
|
|
186
|
+
|
|
187
|
+
if (estimate.vramMaxGb == null) {
|
|
188
|
+
return {
|
|
189
|
+
verdict: 'insufficient data — model size unknown',
|
|
190
|
+
verdictSlug: 'insufficient_data',
|
|
191
|
+
category: 'insufficient_data',
|
|
192
|
+
likelyCause: 'Could not infer a parameter count from the model name — no reliable estimate is possible.',
|
|
193
|
+
evidence,
|
|
194
|
+
fixes: ['Pass --vram-gb to size manually', 'Check the model card for parameter count'],
|
|
195
|
+
badgrCommand: buildServeCommand(model, null),
|
|
196
|
+
estimate,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (gpuNameUnrecognized && availableVramGb === undefined) {
|
|
201
|
+
return {
|
|
202
|
+
verdict: 'insufficient data — GPU not recognized',
|
|
203
|
+
verdictSlug: 'insufficient_data',
|
|
204
|
+
category: 'insufficient_data',
|
|
205
|
+
likelyCause: `GPU name "${options.gpu}" was not recognized and no local GPU was detected — there isn't enough data for a reliable verdict.`,
|
|
206
|
+
evidence,
|
|
207
|
+
fixes: ['Pass --vram-gb <n> to size manually', `Recommended GPU (unverified estimate): ${estimate.recommendedGpuClass}`],
|
|
208
|
+
badgrCommand: buildServeCommand(model, estimate.perGpuVramMaxGb, gpuCount),
|
|
209
|
+
estimate,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (availableVramGb === undefined) {
|
|
214
|
+
return {
|
|
215
|
+
verdict: 'no local GPU detected — showing model estimate only',
|
|
216
|
+
verdictSlug: 'no_gpu_detected',
|
|
217
|
+
category: 'gpu_unavailable',
|
|
218
|
+
likelyCause: 'No local GPU was detected to compare against the model estimate.',
|
|
219
|
+
evidence,
|
|
220
|
+
fixes: [estimate.quantSuggestion, `Recommended GPU: ${estimate.recommendedGpuClass}`],
|
|
221
|
+
badgrCommand: buildServeCommand(model, estimate.perGpuVramMaxGb, gpuCount),
|
|
222
|
+
estimate,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (availableVramGb < estimate.perGpuVramMinGb) {
|
|
227
|
+
return {
|
|
228
|
+
verdict: 'likely fail',
|
|
229
|
+
verdictSlug: 'likely_fail',
|
|
230
|
+
category: 'model_too_large',
|
|
231
|
+
likelyCause: gpuCount > 1
|
|
232
|
+
? 'Model is likely too large for the available VRAM at current settings, even split across the requested GPUs.'
|
|
233
|
+
: 'Model is likely too large for the available VRAM at current settings.',
|
|
234
|
+
evidence,
|
|
235
|
+
fixes: [
|
|
236
|
+
'Lower max model length / context',
|
|
237
|
+
'Reduce concurrency',
|
|
238
|
+
estimate.quantSuggestion,
|
|
239
|
+
gpuCount > 1 ? `Add more GPUs, or use a larger GPU class: ${estimate.recommendedGpuClass}` : `Recommended GPU: ${estimate.recommendedGpuClass}`,
|
|
240
|
+
],
|
|
241
|
+
badgrCommand: buildServeCommand(model, estimate.perGpuVramMaxGb, gpuCount),
|
|
242
|
+
estimate,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (availableVramGb < estimate.perGpuVramMaxGb) {
|
|
247
|
+
return {
|
|
248
|
+
verdict: 'likely okay with conservative settings',
|
|
249
|
+
verdictSlug: 'likely_ok',
|
|
250
|
+
category: 'model_tight_fit',
|
|
251
|
+
likelyCause: 'VRAM / KV cache is a close fit for this model and context length.',
|
|
252
|
+
evidence,
|
|
253
|
+
fixes: ['Lower max model length if you see OOM errors', 'Reduce concurrency for headroom'],
|
|
254
|
+
badgrCommand: buildServeCommand(model, estimate.perGpuVramMaxGb, gpuCount),
|
|
255
|
+
estimate,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
verdict: 'likely okay with conservative settings',
|
|
261
|
+
verdictSlug: 'likely_ok',
|
|
262
|
+
category: 'environment_ok',
|
|
263
|
+
likelyCause: 'Estimated model VRAM fits comfortably within the available VRAM.',
|
|
264
|
+
evidence,
|
|
265
|
+
fixes: ['Lower max model length if you see OOM errors at runtime'],
|
|
266
|
+
badgrCommand: buildServeCommand(model, estimate.perGpuVramMaxGb, gpuCount),
|
|
267
|
+
estimate,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function logVerdict(logText) {
|
|
272
|
+
const result = classifyLog(logText);
|
|
273
|
+
const evidence = result.evidenceLines.map((l) => `Logs contain: ${l}`);
|
|
274
|
+
if (!result.matched) {
|
|
275
|
+
return {
|
|
276
|
+
verdict: 'inconclusive — could not classify this log',
|
|
277
|
+
verdictSlug: 'unknown',
|
|
278
|
+
category: 'unknown',
|
|
279
|
+
likelyCause: 'No recognized failure signature was found in the provided log.',
|
|
280
|
+
evidence,
|
|
281
|
+
fixes: result.fixes,
|
|
282
|
+
badgrCommand: null,
|
|
283
|
+
logResult: result,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
verdict: 'likely fail',
|
|
288
|
+
verdictSlug: 'likely_fail',
|
|
289
|
+
category: result.category,
|
|
290
|
+
likelyCause: result.label,
|
|
291
|
+
evidence,
|
|
292
|
+
fixes: result.fixes,
|
|
293
|
+
badgrCommand: null,
|
|
294
|
+
logResult: result,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function workflowVerdict(workflow) {
|
|
299
|
+
const result = diagnoseWorkflow(workflow);
|
|
300
|
+
const evidence = [
|
|
301
|
+
`Detected workflow: likely ${result.likelyClass}`,
|
|
302
|
+
`Models referenced: ${result.models.length ? result.models.join(', ') : 'none found'}`,
|
|
303
|
+
`Possible custom nodes: ${result.customNodes.length ? result.customNodes.join(', ') : 'none found'}`,
|
|
304
|
+
`Likely VRAM: ${result.vramBucket}`,
|
|
305
|
+
];
|
|
306
|
+
return {
|
|
307
|
+
verdict: result.nodeCount === 0 ? 'inconclusive — could not parse workflow nodes' : 'likely okay with conservative settings',
|
|
308
|
+
verdictSlug: result.nodeCount === 0 ? 'unknown' : 'likely_ok',
|
|
309
|
+
category: 'comfy_workflow',
|
|
310
|
+
likelyCause: `ComfyUI workflow classified as ${result.likelyClass}; verify referenced models and custom nodes are installed.`,
|
|
311
|
+
evidence,
|
|
312
|
+
fixes: [
|
|
313
|
+
result.models.length ? 'Verify each referenced model file exists locally' : 'No model filenames detected — confirm nodes reference valid checkpoints',
|
|
314
|
+
result.customNodes.length ? 'Verify custom nodes are installed before running' : 'No non-core nodes detected',
|
|
315
|
+
`Recommended GPU class: ${result.vramBucket}`,
|
|
316
|
+
],
|
|
317
|
+
badgrCommand: 'badgr comfyui batch --workflow <workflow.json> --max-cost 5',
|
|
318
|
+
workflowResult: result,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function healthVerdict(healthResult, url) {
|
|
323
|
+
const evidence = [`URL: ${redactLine(url)}`, `Latency: ${healthResult.latencyMs}ms`];
|
|
324
|
+
if (healthResult.status != null) evidence.push(`HTTP status: ${healthResult.status}`);
|
|
325
|
+
if (healthResult.modelsCount != null) evidence.push(`/v1/models reports ${healthResult.modelsCount} model(s)`);
|
|
326
|
+
if (healthResult.detail) evidence.push(healthResult.detail);
|
|
327
|
+
|
|
328
|
+
if (!healthResult.reachable) {
|
|
329
|
+
const causeMap = {
|
|
330
|
+
'connection refused': 'Server not started, or the port is not exposed.',
|
|
331
|
+
timeout: 'Server did not respond in time — health check may be too early, or the endpoint is hung.',
|
|
332
|
+
};
|
|
333
|
+
return {
|
|
334
|
+
verdict: 'endpoint not reachable',
|
|
335
|
+
verdictSlug: 'endpoint_not_reachable',
|
|
336
|
+
category: 'endpoint_health',
|
|
337
|
+
likelyCause: causeMap[healthResult.classification] || 'Server not started, port not exposed, or health check too early.',
|
|
338
|
+
evidence: [...evidence, `Classification: ${healthResult.classification}`],
|
|
339
|
+
fixes: ['Confirm the process is running', 'Confirm the port is exposed / forwarded', 'Retry after allowing more startup time'],
|
|
340
|
+
badgrCommand: null,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!healthResult.ready) {
|
|
345
|
+
return {
|
|
346
|
+
verdict: 'endpoint unhealthy',
|
|
347
|
+
verdictSlug: 'endpoint_unhealthy',
|
|
348
|
+
category: 'endpoint_health',
|
|
349
|
+
likelyCause: `Process is responding but returned HTTP ${healthResult.status}.`,
|
|
350
|
+
evidence,
|
|
351
|
+
fixes: ['Check server logs for the underlying error', 'Confirm the model finished loading'],
|
|
352
|
+
badgrCommand: null,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
verdict: 'endpoint ready',
|
|
358
|
+
verdictSlug: 'endpoint_ready',
|
|
359
|
+
category: 'endpoint_health',
|
|
360
|
+
likelyCause: 'Endpoint responded successfully.',
|
|
361
|
+
evidence,
|
|
362
|
+
fixes: [],
|
|
363
|
+
badgrCommand: null,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Priority when multiple sources are given: logs > url > workflow > model.
|
|
368
|
+
// Listed in that order so provided[0] (if any) is always the selected source.
|
|
369
|
+
function describeSources(options) {
|
|
370
|
+
const provided = [];
|
|
371
|
+
if (options.logsPath) provided.push(`--logs ${options.logsPath}`);
|
|
372
|
+
if (options.url) provided.push(`--url ${redactLine(options.url)}`);
|
|
373
|
+
if (options.workflowPath) provided.push(`--workflow ${options.workflowPath}`);
|
|
374
|
+
if (options.model) provided.push(`--model ${options.model}`);
|
|
375
|
+
|
|
376
|
+
if (provided.length === 0) {
|
|
377
|
+
return ['Diagnosing from: baseline environment check (no --logs/--url/--workflow/--model given)'];
|
|
378
|
+
}
|
|
379
|
+
const notes = [`Diagnosing from: ${provided[0]}`];
|
|
380
|
+
if (provided.length > 1) {
|
|
381
|
+
notes.push(`Ignored (lower priority than the source above): ${provided.slice(1).join(', ')}`);
|
|
382
|
+
}
|
|
383
|
+
return notes;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Orchestrates the full read-only diagnosis. Priority when multiple
|
|
388
|
+
* sources are given: logs > url > workflow > model > baseline environment.
|
|
389
|
+
*/
|
|
390
|
+
export async function runGpuDoctor(options = {}, deps = {}) {
|
|
391
|
+
const env = collectEnvironment(deps);
|
|
392
|
+
const baseline = baselineVerdict(env);
|
|
393
|
+
const evidence = [...describeSources(options), ...baseline.evidence, ...diskEvidence(env.disk)];
|
|
394
|
+
|
|
395
|
+
let outcome = null;
|
|
396
|
+
|
|
397
|
+
if (options.logsPath) {
|
|
398
|
+
const readFile = deps.readFileSync || fs.readFileSync;
|
|
399
|
+
let logText;
|
|
400
|
+
try {
|
|
401
|
+
logText = readFile(options.logsPath, 'utf8');
|
|
402
|
+
} catch (err) {
|
|
403
|
+
const wrapped = new Error(`Could not read log file: ${options.logsPath} (${err.message})`);
|
|
404
|
+
wrapped.isToolError = true;
|
|
405
|
+
throw wrapped;
|
|
406
|
+
}
|
|
407
|
+
outcome = logVerdict(logText);
|
|
408
|
+
} else if (options.url) {
|
|
409
|
+
const fetchImpl = deps.fetchImpl;
|
|
410
|
+
const healthResult = await checkHealth(options.url, fetchImpl ? { fetchImpl } : {});
|
|
411
|
+
outcome = healthVerdict(healthResult, options.url);
|
|
412
|
+
} else if (options.workflowPath) {
|
|
413
|
+
const readFile = deps.readFileSync || fs.readFileSync;
|
|
414
|
+
let raw;
|
|
415
|
+
try {
|
|
416
|
+
raw = readFile(options.workflowPath, 'utf8');
|
|
417
|
+
} catch (err) {
|
|
418
|
+
const wrapped = new Error(`Could not read workflow file: ${options.workflowPath} (${err.message})`);
|
|
419
|
+
wrapped.isToolError = true;
|
|
420
|
+
throw wrapped;
|
|
421
|
+
}
|
|
422
|
+
let workflow;
|
|
423
|
+
try {
|
|
424
|
+
workflow = parseWorkflowFile(raw);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
const wrapped = new Error(`Could not parse workflow JSON: ${options.workflowPath} (${err.message})`);
|
|
427
|
+
wrapped.isToolError = true;
|
|
428
|
+
throw wrapped;
|
|
429
|
+
}
|
|
430
|
+
outcome = workflowVerdict(workflow);
|
|
431
|
+
} else if (options.model) {
|
|
432
|
+
outcome = modelVerdict(options.model, env, options);
|
|
433
|
+
} else {
|
|
434
|
+
outcome = baseline;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const combinedEvidence = outcome === baseline ? evidence : [...evidence, ...outcome.evidence];
|
|
438
|
+
|
|
439
|
+
const report = {
|
|
440
|
+
verdict: outcome.verdict,
|
|
441
|
+
verdictSlug: outcome.verdictSlug,
|
|
442
|
+
category: outcome.category,
|
|
443
|
+
likelyCause: outcome.likelyCause,
|
|
444
|
+
evidence: combinedEvidence,
|
|
445
|
+
suggestedFixes: (outcome.fixes || []).filter(Boolean),
|
|
446
|
+
badgrCommand: outcome.badgrCommand || null,
|
|
447
|
+
environment: env,
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
return report;
|
|
451
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
|
|
3
|
+
function round1(n) {
|
|
4
|
+
return Math.round(n * 10) / 10;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read-only nvidia-smi probe. Never mutates the machine.
|
|
9
|
+
*/
|
|
10
|
+
export function detectGpus(execImpl = execFileSync) {
|
|
11
|
+
const result = {
|
|
12
|
+
available: false,
|
|
13
|
+
error: null,
|
|
14
|
+
driverVersion: null,
|
|
15
|
+
cudaVersion: null,
|
|
16
|
+
gpus: [],
|
|
17
|
+
processes: [],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
let smiOut;
|
|
21
|
+
try {
|
|
22
|
+
smiOut = execImpl('nvidia-smi', [], { encoding: 'utf8', timeout: 5000 });
|
|
23
|
+
} catch (err) {
|
|
24
|
+
result.error = err && err.code === 'ENOENT' ? 'nvidia-smi not found' : (err && err.message) || 'nvidia-smi failed';
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
result.available = true;
|
|
29
|
+
|
|
30
|
+
const driverMatch = smiOut.match(/Driver Version:\s*([\d.]+)/);
|
|
31
|
+
if (driverMatch) result.driverVersion = driverMatch[1];
|
|
32
|
+
const cudaMatch = smiOut.match(/CUDA Version:\s*([\d.]+)/);
|
|
33
|
+
if (cudaMatch) result.cudaVersion = cudaMatch[1];
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const csv = execImpl('nvidia-smi', [
|
|
37
|
+
'--query-gpu=index,name,memory.total,memory.free,memory.used,temperature.gpu,power.draw',
|
|
38
|
+
'--format=csv,noheader,nounits',
|
|
39
|
+
], { encoding: 'utf8', timeout: 5000 });
|
|
40
|
+
result.gpus = csv.trim().split(/\r?\n/).filter(Boolean).map((line) => {
|
|
41
|
+
const [index, name, total, free, used, temp, power] = line.split(',').map((s) => s.trim());
|
|
42
|
+
return {
|
|
43
|
+
index: Number(index),
|
|
44
|
+
name,
|
|
45
|
+
vramTotalGb: round1(Number(total) / 1024),
|
|
46
|
+
vramFreeGb: round1(Number(free) / 1024),
|
|
47
|
+
vramUsedGb: round1(Number(used) / 1024),
|
|
48
|
+
temperatureC: temp && temp !== '[N/A]' ? Number(temp) : null,
|
|
49
|
+
powerDrawW: power && power !== '[N/A]' ? Number(power) : null,
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
} catch {
|
|
53
|
+
// csv query unsupported on this driver — plain-text probe above still stands
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const procCsv = execImpl('nvidia-smi', [
|
|
58
|
+
'--query-compute-apps=pid,process_name,used_memory',
|
|
59
|
+
'--format=csv,noheader,nounits',
|
|
60
|
+
], { encoding: 'utf8', timeout: 5000 });
|
|
61
|
+
result.processes = procCsv.trim().split(/\r?\n/).filter(Boolean).map((line) => {
|
|
62
|
+
const [pid, name, mem] = line.split(',').map((s) => s.trim());
|
|
63
|
+
return { pid: Number(pid), name, memoryMb: Number(mem) };
|
|
64
|
+
});
|
|
65
|
+
} catch {
|
|
66
|
+
// no compute-apps support or no processes — leave empty
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Read-only HTTP health probe. A single GET request — never mutates,
|
|
2
|
+
// restarts, or otherwise touches the remote process.
|
|
3
|
+
|
|
4
|
+
export async function checkHealth(url, { fetchImpl = fetch, timeoutMs = 5000 } = {}) {
|
|
5
|
+
const start = Date.now();
|
|
6
|
+
let res;
|
|
7
|
+
try {
|
|
8
|
+
res = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
9
|
+
} catch (err) {
|
|
10
|
+
const latencyMs = Date.now() - start;
|
|
11
|
+
if (err && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
|
|
12
|
+
return { reachable: false, ready: false, status: null, latencyMs, error: 'timeout', classification: 'timeout' };
|
|
13
|
+
}
|
|
14
|
+
const message = (err && err.message) || String(err);
|
|
15
|
+
if (/ECONNREFUSED/.test(message) || (err && err.cause && err.cause.code === 'ECONNREFUSED')) {
|
|
16
|
+
return { reachable: false, ready: false, status: null, latencyMs, error: 'connection refused', classification: 'connection refused' };
|
|
17
|
+
}
|
|
18
|
+
return { reachable: false, ready: false, status: null, latencyMs, error: message, classification: 'connection error' };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const latencyMs = Date.now() - start;
|
|
22
|
+
let modelsCount = null;
|
|
23
|
+
let serviceKind = null;
|
|
24
|
+
let detail = null;
|
|
25
|
+
try {
|
|
26
|
+
const body = await res.clone().json();
|
|
27
|
+
if (body && Array.isArray(body.data)) {
|
|
28
|
+
// OpenAI-compatible /v1/models
|
|
29
|
+
modelsCount = body.data.length;
|
|
30
|
+
serviceKind = 'openai';
|
|
31
|
+
} else if (body && Array.isArray(body.devices)) {
|
|
32
|
+
// ComfyUI /system_stats
|
|
33
|
+
serviceKind = 'comfyui';
|
|
34
|
+
detail = `ComfyUI system stats — ${body.devices.length} device(s) reported`;
|
|
35
|
+
} else if (body && typeof body.status === 'string') {
|
|
36
|
+
// llama.cpp /health, and similar {status: "..."} health endpoints
|
|
37
|
+
serviceKind = 'llamacpp';
|
|
38
|
+
detail = `status: ${body.status}`;
|
|
39
|
+
} else if (body && typeof body.healthy === 'boolean') {
|
|
40
|
+
serviceKind = 'generic';
|
|
41
|
+
detail = `healthy: ${body.healthy}`;
|
|
42
|
+
} else if (body && typeof body.ok === 'boolean') {
|
|
43
|
+
serviceKind = 'generic';
|
|
44
|
+
detail = `ok: ${body.ok}`;
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// non-JSON body — check for a bare text status before giving up
|
|
48
|
+
try {
|
|
49
|
+
const text = (await res.clone().text()).trim();
|
|
50
|
+
if (text && /^(ok|healthy|ready|up)$/i.test(text)) {
|
|
51
|
+
serviceKind = 'generic';
|
|
52
|
+
detail = `response: ${text}`;
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// empty or unreadable body — fine, this is a best-effort read
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (res.ok) {
|
|
60
|
+
return { reachable: true, ready: true, status: res.status, latencyMs, modelsCount, serviceKind, detail, classification: 'endpoint ready' };
|
|
61
|
+
}
|
|
62
|
+
return { reachable: true, ready: false, status: res.status, latencyMs, modelsCount, serviceKind, detail, classification: 'endpoint unhealthy' };
|
|
63
|
+
}
|