llm-checker 3.5.3 → 3.5.4

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 CHANGED
@@ -531,19 +531,24 @@ Hardware Tier: HIGH | Models Analyzed: 205
531
531
  Coding:
532
532
  qwen2.5-coder:14b (14B)
533
533
  Score: 78/100
534
+ Fine-tuning: LoRA+QLoRA
534
535
  Command: ollama pull qwen2.5-coder:14b
535
536
 
536
537
  Reasoning:
537
538
  deepseek-r1:14b (14B)
538
539
  Score: 86/100
540
+ Fine-tuning: QLoRA
539
541
  Command: ollama pull deepseek-r1:14b
540
542
 
541
543
  Multimodal:
542
544
  llama3.2-vision:11b (11B)
543
545
  Score: 83/100
546
+ Fine-tuning: LoRA+QLoRA
544
547
  Command: ollama pull llama3.2-vision:11b
545
548
  ```
546
549
 
550
+ `check`, `recommend`, and `ai-check` include a fine-tuning suitability label in output to help choose between Full FT, LoRA, and QLoRA paths.
551
+
547
552
  ### `search` — Model Search
548
553
 
549
554
  ```bash
@@ -23,6 +23,7 @@ const {
23
23
  getRuntimeDisplayName,
24
24
  getRuntimeCommandSet
25
25
  } = require('../src/runtime/runtime-support');
26
+ const { evaluateFineTuningSupport } = require('../src/models/fine-tuning-support');
26
27
  const { CalibrationManager } = require('../src/calibration/calibration-manager');
27
28
  const { SUPPORTED_CALIBRATION_OBJECTIVES } = require('../src/calibration/schemas');
28
29
  const {
@@ -1203,7 +1204,7 @@ function displayLegacyRecommendations(recommendations) {
1203
1204
  console.log(chalk.cyan('╰'));
1204
1205
  }
1205
1206
 
1206
- function displayIntelligentRecommendations(intelligentData) {
1207
+ function displayIntelligentRecommendations(intelligentData, hardware = null) {
1207
1208
  if (!intelligentData || !intelligentData.summary) return;
1208
1209
 
1209
1210
  const { summary, recommendations } = intelligentData;
@@ -1220,10 +1221,12 @@ function displayIntelligentRecommendations(intelligentData) {
1220
1221
  // Mostrar mejor modelo general
1221
1222
  if (summary.best_overall) {
1222
1223
  const best = summary.best_overall;
1224
+ const bestFineTuning = evaluateFineTuningSupport(best, hardware || {});
1223
1225
  console.log(chalk.red('│') + ` ${chalk.bold.yellow('BEST OVERALL:')} ${chalk.green.bold(best.name)}`);
1224
1226
  console.log(chalk.red('│') + ` Command: ${chalk.cyan.bold(best.command)}`);
1225
1227
  console.log(chalk.red('│') + ` Score: ${chalk.yellow.bold(best.score)}/100 | Category: ${chalk.magenta(best.category)}`);
1226
1228
  console.log(chalk.red('│') + ` Quantization: ${chalk.white.bold(best.quantization || 'Q4_K_M')}`);
1229
+ console.log(chalk.red('│') + ` Fine-tuning: ${chalk.blue.bold(bestFineTuning.shortLabel)}`);
1227
1230
  console.log(chalk.red('│'));
1228
1231
  }
1229
1232
 
@@ -1242,11 +1245,13 @@ function displayIntelligentRecommendations(intelligentData) {
1242
1245
  const icon = categories[category] || 'Other';
1243
1246
  const categoryName = category.charAt(0).toUpperCase() + category.slice(1);
1244
1247
  const scoreColor = getScoreColor(model.score);
1248
+ const fineTuningSupport = evaluateFineTuningSupport(model, hardware || {});
1245
1249
 
1246
1250
  console.log(chalk.red('│') + ` ${chalk.bold.white(categoryName)} (${icon}):`);
1247
1251
  console.log(chalk.red('│') + ` ${chalk.green(model.name)} (${model.size})`);
1248
1252
  console.log(chalk.red('│') + ` Score: ${scoreColor.bold(model.score)}/100 | Pulls: ${chalk.gray(model.pulls?.toLocaleString() || 'N/A')}`);
1249
1253
  console.log(chalk.red('│') + ` Quantization: ${chalk.white.bold(model.quantization || 'Q4_K_M')}`);
1254
+ console.log(chalk.red('│') + ` Fine-tuning: ${chalk.blue.bold(fineTuningSupport.shortLabel)}`);
1250
1255
  console.log(chalk.red('│') + ` Command: ${chalk.cyan.bold(model.command)}`);
1251
1256
  console.log(chalk.red('│'));
1252
1257
  });
@@ -2079,6 +2084,8 @@ async function displayModelRecommendations(analysis, hardware, useCase = 'genera
2079
2084
  const realSize = getRealSizeFromOllamaCache(model) || estimateModelSize(model);
2080
2085
  console.log(`Size: ${chalk.white(realSize)}`);
2081
2086
  console.log(`Compatibility Score: ${chalk.green.bold(model.adjustedScore || model.score || 'N/A')}/100`);
2087
+ const fineTuningSupport = evaluateFineTuningSupport(model, hardware);
2088
+ console.log(`Fine-tuning: ${chalk.blue.bold(fineTuningSupport.shortLabel)}`);
2082
2089
 
2083
2090
  if (index === 0) {
2084
2091
  console.log(`Reason: ${chalk.gray(reason)}`);
@@ -3628,7 +3635,7 @@ Calibrated routing examples:
3628
3635
  displaySystemInfo(hardware, { summary: { hardwareTier: intelligentRecommendations.summary.hardware_tier } });
3629
3636
 
3630
3637
  // Mostrar recomendaciones
3631
- displayIntelligentRecommendations(intelligentRecommendations);
3638
+ displayIntelligentRecommendations(intelligentRecommendations, hardware);
3632
3639
  displayCalibratedRoutingDecision('recommend', calibratedPolicy, routeDecision, routingPreference.warnings);
3633
3640
 
3634
3641
  if (policyConfig && policyEvaluation && policyEnforcement) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-checker",
3
- "version": "3.5.3",
3
+ "version": "3.5.4",
4
4
  "description": "Intelligent CLI tool with AI-powered model selection that analyzes your hardware and recommends optimal LLM models for your system",
5
5
  "bin": {
6
6
  "llm-checker": "bin/cli.js",
@@ -248,13 +248,20 @@ class ROCmDetector {
248
248
  timeout: 10000
249
249
  });
250
250
 
251
- // Parse memory info
252
- const memMatches = memInfo.matchAll(/GPU\[(\d+)\].*?Total.*?:\s*(\d+)/g);
251
+ // Parse memory info. Newer rocm-smi reports bytes "(B)" while some
252
+ // systems expose MiB; normalize to GB safely.
253
253
  const gpuMemory = {};
254
- for (const match of memMatches) {
255
- const idx = parseInt(match[1]);
256
- const memMB = parseInt(match[2]);
257
- gpuMemory[idx] = Math.round(memMB / 1024); // Convert to GB
254
+ const memLines = String(memInfo || '').split('\n');
255
+ for (const line of memLines) {
256
+ const lineMatch = line.match(/GPU\[(\d+)\].*?Total.*?Memory\s*(?:\(([^)]+)\))?\s*:\s*(\d+)/i);
257
+ if (!lineMatch) continue;
258
+
259
+ const idx = parseInt(lineMatch[1], 10);
260
+ const unitHint = lineMatch[2] || '';
261
+ const rawValue = parseInt(lineMatch[3], 10);
262
+
263
+ if (!Number.isFinite(rawValue) || rawValue <= 0) continue;
264
+ gpuMemory[idx] = this.normalizeRocmMemoryToGB(rawValue, unitHint);
258
265
  }
259
266
 
260
267
  // Get temperature and utilization
@@ -312,6 +319,45 @@ class ROCmDetector {
312
319
  }
313
320
  }
314
321
 
322
+ /**
323
+ * Normalize rocm-smi memory values to GB.
324
+ * rocm-smi may report bytes "(B)" or MiB depending on version/system.
325
+ */
326
+ normalizeRocmMemoryToGB(value, unitHint = '') {
327
+ const numericValue = Number(value);
328
+ if (!Number.isFinite(numericValue) || numericValue <= 0) {
329
+ return 0;
330
+ }
331
+
332
+ const unit = String(unitHint || '').toLowerCase();
333
+
334
+ if (unit.includes('gib') || unit.includes('gb')) {
335
+ return Math.round(numericValue);
336
+ }
337
+
338
+ if (unit.includes('mib') || unit.includes('mb')) {
339
+ return Math.round(numericValue / 1024);
340
+ }
341
+
342
+ if (unit.includes('kib') || unit.includes('kb')) {
343
+ return Math.round(numericValue / (1024 * 1024));
344
+ }
345
+
346
+ if (unit === 'b' || unit === 'bytes') {
347
+ return Math.round(numericValue / (1024 ** 3));
348
+ }
349
+
350
+ // Unit was not provided. Use value magnitude heuristics.
351
+ if (numericValue >= 1024 ** 3) {
352
+ return Math.round(numericValue / (1024 ** 3));
353
+ }
354
+ if (numericValue >= 1024) {
355
+ return Math.round(numericValue / 1024);
356
+ }
357
+
358
+ return Math.round(numericValue);
359
+ }
360
+
315
361
  /**
316
362
  * Detect GPUs via rocminfo
317
363
  */
@@ -255,20 +255,43 @@ class HardwareDetector {
255
255
  }
256
256
 
257
257
  const primaryType = unified.primary.type || 'cpu';
258
- if (primaryType === 'cpu') {
258
+ const hasFallbackDedicatedGpu = Boolean(
259
+ primaryType === 'cpu' &&
260
+ unified.systemGpu?.available &&
261
+ Array.isArray(unified.systemGpu.gpus) &&
262
+ unified.systemGpu.gpus.some((gpu) => gpu.type === 'dedicated')
263
+ );
264
+
265
+ if (primaryType === 'cpu' && !hasFallbackDedicatedGpu) {
259
266
  return;
260
267
  }
261
268
 
262
269
  const summary = unified.summary;
263
- const backendInfo = unified.backends?.[primaryType]?.info || {};
264
- const backendGPUs = Array.isArray(backendInfo.gpus) ? backendInfo.gpus : [];
265
- const gpuCount = summary.gpuCount || backendGPUs.length || systemInfo.gpu.gpuCount || 1;
270
+ const backendInfo = hasFallbackDedicatedGpu
271
+ ? unified.systemGpu
272
+ : (unified.backends?.[primaryType]?.info || {});
266
273
 
267
- const totalVRAM = typeof summary.totalVRAM === 'number' ? summary.totalVRAM : systemInfo.gpu.vram;
268
- const perGPUVRAM = backendGPUs[0]?.memory?.total
269
- || (gpuCount > 0 && totalVRAM > 0 ? Math.round(totalVRAM / gpuCount) : 0);
270
-
271
- const modelFromUnified = summary.gpuInventory || summary.gpuModel || systemInfo.gpu.model;
274
+ const backendGPUs = Array.isArray(backendInfo.gpus) ? backendInfo.gpus : [];
275
+ const dedicatedBackendGPUs = backendGPUs.filter((gpu) => gpu?.type !== 'integrated');
276
+
277
+ const gpuCount = summary.gpuCount ||
278
+ dedicatedBackendGPUs.length ||
279
+ backendGPUs.length ||
280
+ systemInfo.gpu.gpuCount ||
281
+ 1;
282
+
283
+ const totalVRAMFromUnified = typeof summary.totalVRAM === 'number' ? summary.totalVRAM : 0;
284
+ const totalVRAMFromFallback = dedicatedBackendGPUs.reduce((sum, gpu) => {
285
+ const amount = Number(gpu?.memory?.total || gpu?.memoryTotal || 0);
286
+ return sum + (Number.isFinite(amount) ? amount : 0);
287
+ }, 0);
288
+ const totalVRAM = totalVRAMFromUnified || totalVRAMFromFallback || systemInfo.gpu.vram;
289
+ const perGPUVRAM = dedicatedBackendGPUs[0]?.memory?.total ||
290
+ backendGPUs[0]?.memory?.total ||
291
+ (gpuCount > 0 && totalVRAM > 0 ? Math.round(totalVRAM / gpuCount) : 0);
292
+
293
+ const fallbackModel = dedicatedBackendGPUs[0]?.name || backendGPUs[0]?.name || null;
294
+ const modelFromUnified = summary.gpuInventory || summary.gpuModel || fallbackModel || systemInfo.gpu.model;
272
295
  const vendor = this.inferVendorFromGPUModel(modelFromUnified, systemInfo.gpu.vendor);
273
296
 
274
297
  systemInfo.gpu = {
@@ -277,11 +300,11 @@ class HardwareDetector {
277
300
  vendor,
278
301
  vram: totalVRAM || systemInfo.gpu.vram,
279
302
  vramPerGPU: perGPUVRAM || systemInfo.gpu.vramPerGPU || 0,
280
- dedicated: primaryType !== 'metal',
303
+ dedicated: hasFallbackDedicatedGpu ? true : primaryType !== 'metal',
281
304
  gpuCount,
282
305
  isMultiGPU: Boolean(summary.isMultiGPU || gpuCount > 1),
283
306
  gpuInventory: summary.gpuInventory || null,
284
- backend: primaryType,
307
+ backend: hasFallbackDedicatedGpu ? 'generic' : primaryType,
285
308
  driverVersion: backendInfo.driver || systemInfo.gpu.driverVersion
286
309
  };
287
310
  } catch (error) {
@@ -10,6 +10,7 @@ const ROCmDetector = require('./backends/rocm-detector');
10
10
  const IntelDetector = require('./backends/intel-detector');
11
11
  const CPUDetector = require('./backends/cpu-detector');
12
12
  const si = require('systeminformation');
13
+ const { execSync } = require('child_process');
13
14
 
14
15
  class UnifiedDetector {
15
16
  constructor() {
@@ -363,6 +364,20 @@ class UnifiedDetector {
363
364
  })
364
365
  .filter(Boolean);
365
366
 
367
+ if (process.platform === 'linux') {
368
+ const lspciControllers = this.detectLinuxLspciGpus();
369
+ const knownKeys = new Set(
370
+ normalized.map((gpu) => this.getGpuMatchKey(gpu.name)).filter(Boolean)
371
+ );
372
+
373
+ for (const gpu of lspciControllers) {
374
+ const nameKey = this.getGpuMatchKey(gpu.name);
375
+ if (!nameKey || knownKeys.has(nameKey)) continue;
376
+ normalized.push(gpu);
377
+ knownKeys.add(nameKey);
378
+ }
379
+ }
380
+
366
381
  if (normalized.length === 0) {
367
382
  return {
368
383
  available: false,
@@ -381,7 +396,7 @@ class UnifiedDetector {
381
396
 
382
397
  return {
383
398
  available: true,
384
- source: 'systeminformation',
399
+ source: process.platform === 'linux' ? 'systeminformation+lspci' : 'systeminformation',
385
400
  gpus: normalized,
386
401
  totalVRAM,
387
402
  isMultiGPU: dedicated.length > 1,
@@ -389,6 +404,66 @@ class UnifiedDetector {
389
404
  };
390
405
  }
391
406
 
407
+ detectLinuxLspciGpus() {
408
+ try {
409
+ const lspciOutput = execSync('lspci -nn | grep -Ei "VGA|3D|Display"', {
410
+ encoding: 'utf8',
411
+ timeout: 8000,
412
+ stdio: ['pipe', 'pipe', 'pipe']
413
+ });
414
+ return this.parseLinuxLspciGpus(lspciOutput);
415
+ } catch (error) {
416
+ return [];
417
+ }
418
+ }
419
+
420
+ parseLinuxLspciGpus(lspciOutput = '') {
421
+ const lines = String(lspciOutput || '')
422
+ .split('\n')
423
+ .map((line) => line.trim())
424
+ .filter(Boolean);
425
+
426
+ const results = [];
427
+ const seen = new Set();
428
+
429
+ for (const line of lines) {
430
+ const lineLower = line.toLowerCase();
431
+ const isNvidia = lineLower.includes('nvidia');
432
+ const isAMD = lineLower.includes('amd') || lineLower.includes('ati') || lineLower.includes('radeon');
433
+ const isIntel = lineLower.includes('intel');
434
+
435
+ if (!isNvidia && !isAMD && !isIntel) continue;
436
+
437
+ const genericName = line
438
+ .replace(/^[0-9a-f:.]+\s+/i, '')
439
+ .replace(/\(rev\s+[0-9a-f]+\)$/i, '')
440
+ .trim();
441
+
442
+ const bracketName = line.match(/\[(?![0-9a-f]{4}:[0-9a-f]{4}\])([^\]]+)\]\s*\[[0-9a-f]{4}:[0-9a-f]{4}\]/i);
443
+ const name = (bracketName?.[1] || genericName || 'Unknown GPU').replace(/\s+/g, ' ').trim();
444
+ if (!name || name.toLowerCase() === 'unknown gpu') continue;
445
+
446
+ const isIntegrated = this.isIntegratedGPUModel(name) || isIntel;
447
+ let vram = this.estimateFallbackVRAM(name);
448
+ if (isIntegrated) {
449
+ vram = 0;
450
+ }
451
+
452
+ const dedupeKey = `${name.toLowerCase()}|${isIntegrated ? 'i' : 'd'}`;
453
+ if (seen.has(dedupeKey)) continue;
454
+ seen.add(dedupeKey);
455
+
456
+ results.push({
457
+ name,
458
+ vendor: isNvidia ? 'NVIDIA' : (isAMD ? 'AMD' : 'Intel'),
459
+ type: isIntegrated ? 'integrated' : 'dedicated',
460
+ memory: { total: vram }
461
+ });
462
+ }
463
+
464
+ return results;
465
+ }
466
+
392
467
  normalizeFallbackVRAM(value) {
393
468
  const num = Number(value);
394
469
  if (!Number.isFinite(num) || num <= 0) return 0;
@@ -426,6 +501,7 @@ class UnifiedDetector {
426
501
  lower.includes('iris') ||
427
502
  lower.includes('uhd') ||
428
503
  lower.includes('hd graphics') ||
504
+ (lower.includes('radeon') && !lower.includes('radeon rx') && /\b\d{3,4}m\b/.test(lower)) ||
429
505
  lower.includes('radeon graphics') ||
430
506
  lower.includes('radeon(tm) graphics') ||
431
507
  lower.includes('vega') ||
@@ -454,6 +530,23 @@ class UnifiedDetector {
454
530
  return 0;
455
531
  }
456
532
 
533
+ getGpuMatchKey(name) {
534
+ const lower = String(name || '').toLowerCase();
535
+ if (!lower) return '';
536
+
537
+ const familyMatch = lower.match(/\b(rtx|gtx|rx|arc)\s*([0-9]{3,4})\b/);
538
+ if (familyMatch) {
539
+ return `${familyMatch[1]}${familyMatch[2]}`;
540
+ }
541
+
542
+ const concise = lower
543
+ .replace(/nvidia|amd|ati|intel|corporation|geforce|radeon|graphics/g, '')
544
+ .replace(/\s+/g, ' ')
545
+ .trim();
546
+
547
+ return concise || lower;
548
+ }
549
+
457
550
  /**
458
551
  * Generate hardware fingerprint for benchmarks
459
552
  */
@@ -11,6 +11,7 @@ const crypto = require('crypto');
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
13
  const fetch = require('../utils/fetch');
14
+ const { evaluateFineTuningSupport } = require('./fine-tuning-support');
14
15
 
15
16
  class AICheckSelector {
16
17
  constructor() {
@@ -606,6 +607,7 @@ Return JSON with this structure:
606
607
  chalk.bgMagenta.white.bold(' Det Score '),
607
608
  chalk.bgMagenta.white.bold(' AI Score '),
608
609
  chalk.bgMagenta.white.bold(' Final '),
610
+ chalk.bgMagenta.white.bold(' Fine-tune '),
609
611
  chalk.bgMagenta.white.bold(' RAM '),
610
612
  chalk.bgMagenta.white.bold(' Speed '),
611
613
  chalk.bgMagenta.white.bold(' Status ')
@@ -621,6 +623,7 @@ Return JSON with this structure:
621
623
  const finalScore = `${Math.round(candidate.finalScore)}/100`;
622
624
  const ram = `${candidate.requiredGB}/${Math.round(results.hardware.usableMemGB)}GB`;
623
625
  const speed = `${candidate.estTPS.toFixed(0)}t/s`;
626
+ const fineTuningSupport = evaluateFineTuningSupport(candidate, results.hardware || {});
624
627
 
625
628
  let statusDisplay, modelDisplay;
626
629
  if (isInstalled) {
@@ -637,6 +640,7 @@ Return JSON with this structure:
637
640
  this.getScoreColor(candidate.score)(detScore),
638
641
  candidate.aiScore ? this.getScoreColor(candidate.aiScore)(aiScore) : chalk.gray(aiScore),
639
642
  this.getScoreColor(candidate.finalScore)(finalScore),
643
+ fineTuningSupport.shortLabel,
640
644
  ram,
641
645
  speed,
642
646
  statusDisplay
@@ -648,11 +652,13 @@ Return JSON with this structure:
648
652
 
649
653
  // Best recommendation section
650
654
  const best = results.candidates[0];
655
+ const bestFineTuning = evaluateFineTuningSupport(best, results.hardware || {});
651
656
  console.log('\n' + chalk.bgGreen.black.bold(' AI-POWERED RECOMMENDATION '));
652
657
  console.log(chalk.green('╭' + '─'.repeat(50)));
653
658
  console.log(chalk.green('│') + ` Best Model: ${chalk.cyan.bold(best.meta.name || best.meta.model_identifier)}`);
654
659
  console.log(chalk.green('│') + ` Final Score: ${this.getScoreColor(best.finalScore)(Math.round(best.finalScore) + '/100')}`);
655
660
  console.log(chalk.green('│') + ` ⚖️ Det: ${Math.round(best.score)} + AI: ${best.aiScore ? Math.round(best.aiScore) : 'N/A'}`);
661
+ console.log(chalk.green('│') + ` Fine-tuning: ${chalk.blue.bold(bestFineTuning.shortLabel)}`);
656
662
  console.log(chalk.green('│'));
657
663
 
658
664
  if (best.meta.installed) {
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Fine-tuning suitability helper.
3
+ * Provides a simple hardware-aware estimate for Full FT / LoRA / QLoRA.
4
+ */
5
+
6
+ function toNumber(value) {
7
+ const num = Number(value);
8
+ return Number.isFinite(num) ? num : 0;
9
+ }
10
+
11
+ function parseModelParamsB(model = {}) {
12
+ const directParams = toNumber(model?.paramsB || model?.meta?.paramsB);
13
+ if (directParams > 0) return directParams;
14
+
15
+ const candidates = [
16
+ model?.size,
17
+ model?.model_identifier,
18
+ model?.identifier,
19
+ model?.name,
20
+ model?.meta?.model_identifier,
21
+ model?.meta?.name
22
+ ];
23
+
24
+ for (const candidate of candidates) {
25
+ const text = String(candidate || '');
26
+ if (!text) continue;
27
+
28
+ const billionMatch = text.match(/(\d+\.?\d*)\s*[bB]\b/);
29
+ if (billionMatch) {
30
+ return toNumber(billionMatch[1]);
31
+ }
32
+
33
+ const millionMatch = text.match(/(\d+\.?\d*)\s*[mM]\b/);
34
+ if (millionMatch) {
35
+ return toNumber(millionMatch[1]) / 1000;
36
+ }
37
+
38
+ const gbMatch = text.match(/(\d+\.?\d*)\s*[gG][bB]\b/);
39
+ if (gbMatch) {
40
+ // Q4-style rule-of-thumb: ~0.5GB per 1B params.
41
+ return toNumber(gbMatch[1]) / 0.5;
42
+ }
43
+ }
44
+
45
+ const reqRam = toNumber(model?.requirements?.ram);
46
+ if (reqRam > 0) {
47
+ // Heuristic fallback: many model entries use ~1.2-1.5x RAM multiplier.
48
+ return Math.max(0, reqRam / 1.5);
49
+ }
50
+
51
+ return 0;
52
+ }
53
+
54
+ function getHardwareBudgetGB(hardware = {}) {
55
+ const totalRam = toNumber(
56
+ hardware?.memory?.total ||
57
+ hardware?.memory?.totalGB ||
58
+ hardware?.summary?.systemRAM ||
59
+ hardware?.systemRAM
60
+ );
61
+ const totalVram = toNumber(
62
+ hardware?.gpu?.vram ||
63
+ hardware?.gpu?.vramGB ||
64
+ hardware?.summary?.totalVRAM ||
65
+ hardware?.totalVRAM
66
+ );
67
+
68
+ const gpuType = String(hardware?.gpu?.type || '').toLowerCase();
69
+ const gpuModel = String(hardware?.gpu?.model || '').toLowerCase();
70
+ const supportsMetal = Boolean(hardware?.acceleration?.supports_metal);
71
+
72
+ const isAppleUnified = supportsMetal ||
73
+ gpuType.includes('apple') ||
74
+ gpuModel.includes('apple') ||
75
+ /\bm[1-4]\b/.test(gpuModel);
76
+
77
+ const hasDedicatedLikeGpu = Boolean(
78
+ hardware?.gpu?.dedicated ||
79
+ totalVram > 0 ||
80
+ gpuType.includes('nvidia') ||
81
+ gpuType.includes('amd')
82
+ );
83
+
84
+ if (hasDedicatedLikeGpu && totalVram > 0) {
85
+ return {
86
+ budgetGB: totalVram,
87
+ accelerator: 'gpu'
88
+ };
89
+ }
90
+
91
+ if (isAppleUnified && totalRam > 0) {
92
+ // Keep headroom for OS and background apps.
93
+ return {
94
+ budgetGB: Math.max(0, Math.floor(totalRam * 0.7)),
95
+ accelerator: 'unified'
96
+ };
97
+ }
98
+
99
+ return {
100
+ budgetGB: 0,
101
+ accelerator: 'none'
102
+ };
103
+ }
104
+
105
+ function estimateFineTuningMemoryGB(paramsB) {
106
+ // Coarse practical estimates for local fine-tuning workflows.
107
+ return {
108
+ fullFineTuning: Math.ceil(paramsB * 8 + 4),
109
+ lora: Math.ceil(paramsB * 1.8 + 3),
110
+ qlora: Math.ceil(paramsB * 0.8 + 2)
111
+ };
112
+ }
113
+
114
+ function evaluateFineTuningSupport(model = {}, hardware = {}) {
115
+ const paramsB = parseModelParamsB(model);
116
+ if (paramsB <= 0) {
117
+ return {
118
+ method: 'unknown',
119
+ label: 'Unknown',
120
+ shortLabel: 'Unknown',
121
+ supports: {
122
+ fullFineTuning: false,
123
+ lora: false,
124
+ qlora: false
125
+ },
126
+ requirementsGB: {
127
+ fullFineTuning: 0,
128
+ lora: 0,
129
+ qlora: 0
130
+ },
131
+ paramsB: 0,
132
+ budgetGB: 0,
133
+ accelerator: 'none'
134
+ };
135
+ }
136
+
137
+ const { budgetGB, accelerator } = getHardwareBudgetGB(hardware);
138
+ const requirementsGB = estimateFineTuningMemoryGB(paramsB);
139
+
140
+ const canFull = budgetGB >= requirementsGB.fullFineTuning;
141
+ const canLoRA = budgetGB >= requirementsGB.lora;
142
+ const canQLoRA = budgetGB >= requirementsGB.qlora;
143
+
144
+ if (canFull) {
145
+ return {
146
+ method: 'full_ft',
147
+ label: 'Full FT / LoRA / QLoRA',
148
+ shortLabel: 'Full+LoRA+QLoRA',
149
+ supports: {
150
+ fullFineTuning: true,
151
+ lora: true,
152
+ qlora: true
153
+ },
154
+ requirementsGB,
155
+ paramsB,
156
+ budgetGB,
157
+ accelerator
158
+ };
159
+ }
160
+
161
+ if (canLoRA) {
162
+ return {
163
+ method: 'lora',
164
+ label: 'LoRA / QLoRA',
165
+ shortLabel: 'LoRA+QLoRA',
166
+ supports: {
167
+ fullFineTuning: false,
168
+ lora: true,
169
+ qlora: true
170
+ },
171
+ requirementsGB,
172
+ paramsB,
173
+ budgetGB,
174
+ accelerator
175
+ };
176
+ }
177
+
178
+ if (canQLoRA) {
179
+ return {
180
+ method: 'qlora',
181
+ label: 'QLoRA only',
182
+ shortLabel: 'QLoRA',
183
+ supports: {
184
+ fullFineTuning: false,
185
+ lora: false,
186
+ qlora: true
187
+ },
188
+ requirementsGB,
189
+ paramsB,
190
+ budgetGB,
191
+ accelerator
192
+ };
193
+ }
194
+
195
+ return {
196
+ method: 'none',
197
+ label: accelerator === 'none' ? 'No accelerator' : 'Not suitable',
198
+ shortLabel: accelerator === 'none' ? 'No accel' : 'No FT',
199
+ supports: {
200
+ fullFineTuning: false,
201
+ lora: false,
202
+ qlora: false
203
+ },
204
+ requirementsGB,
205
+ paramsB,
206
+ budgetGB,
207
+ accelerator
208
+ };
209
+ }
210
+
211
+ module.exports = {
212
+ parseModelParamsB,
213
+ estimateFineTuningMemoryGB,
214
+ evaluateFineTuningSupport
215
+ };