mirror of
https://github.com/signerless/llm-checker.git
synced 2026-10-19 18:04:26 +00:00
2802 lines
116 KiB
JavaScript
2802 lines
116 KiB
JavaScript
/**
|
|
* LLM-Checker: Deterministic Model Selection Algorithm (Spec v1.0)
|
|
*
|
|
* A two-phase selector that picks the best Ollama model + quantization
|
|
* for a given machine and task category.
|
|
*/
|
|
|
|
const { normalizePrecision, precisionProfile, capabilitiesOf, detectedBackend, memoryBudgetGB, classifyFit } = require('./ranking-contract');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const { applyCpuOnlyOverride, resolveCpuOnlyMode } = require('../hardware/cpu-only');
|
|
const { spawn } = require('child_process');
|
|
const OllamaClient = require('../ollama/client');
|
|
const { DETERMINISTIC_WEIGHTS } = require('./scoring-config');
|
|
const { filterModelsBySafety, isUncensoredModel } = require('./model-safety');
|
|
const {
|
|
parseBillionsValue: parseMoEBillionsValue,
|
|
parsePositiveNumber: parseMoEPositiveNumber,
|
|
normalizeMoERuntime,
|
|
extractMoEMetadata: extractCanonicalMoEMetadata,
|
|
resolveMoEParameterProfile,
|
|
estimateMoESpeedMultiplier
|
|
} = require('./moe-assumptions');
|
|
|
|
class DeterministicModelSelector {
|
|
constructor(options = {}) {
|
|
this.hasExplicitCpuOnly = Object.prototype.hasOwnProperty.call(options, 'cpuOnly');
|
|
this.cpuOnly = resolveCpuOnlyMode(options.cpuOnly);
|
|
this.activeCpuOnly = this.cpuOnly;
|
|
this.catalogPath = path.join(__dirname, 'catalog.json');
|
|
this.benchCachePath = path.join(os.homedir(), '.llm-checker', 'bench.json');
|
|
this.ollamaClient = options.ollamaClient || new OllamaClient({ cpuOnly: this.cpuOnly });
|
|
this.ollamaCachePaths = [
|
|
path.join(os.homedir(), '.llm-checker', 'cache', 'ollama', 'ollama-detailed-models.json'),
|
|
path.join(__dirname, '../ollama/.cache/ollama-detailed-models.json')
|
|
];
|
|
|
|
// Quality priors table
|
|
this.baseQualityByParams = {
|
|
0.5: 45, 1: 45, 1.5: 45,
|
|
2: 60, 3: 60, 4: 60,
|
|
7: 75, 8: 75, 9: 75,
|
|
13: 82, 14: 82, 15: 82,
|
|
30: 89, 32: 89, 34: 89,
|
|
70: 95, 72: 95
|
|
};
|
|
|
|
// Family quality bumps
|
|
this.familyBumps = {
|
|
'qwen2.5': 2,
|
|
'qwen3': 4,
|
|
'gemma3': 3,
|
|
'deepseek': 3,
|
|
'deepseek-r1': 5,
|
|
'deepseek-coder': 4,
|
|
'mistral': 1,
|
|
'llama3.1': 1,
|
|
'llama3.2': 2,
|
|
'gemma2': 1,
|
|
'yi': -3,
|
|
'yi-coder': 1,
|
|
'phi-3': 0,
|
|
'granite': 0,
|
|
'solar': 0,
|
|
'starcoder': 1,
|
|
'minicpm': 0,
|
|
'llava': 0
|
|
};
|
|
|
|
// Quantization penalties
|
|
this.quantPenalties = {
|
|
'Q8_0': 0,
|
|
'Q6_K': -1,
|
|
'Q5_K_M': -2,
|
|
'Q4_K_M': -5,
|
|
'Q3_K': -8,
|
|
'Q2_K': -12
|
|
};
|
|
|
|
// Quantization hierarchy (best to worst)
|
|
this.quantHierarchy = ['Q8_0', 'Q6_K', 'Q5_K_M', 'Q4_K_M', 'Q3_K', 'Q2_K'];
|
|
|
|
// Quantization speed multipliers
|
|
this.quantSpeedMultipliers = {
|
|
'Q8_0': 0.8,
|
|
'Q6_K': 0.95,
|
|
'Q5_K_M': 1.00,
|
|
'Q4_K_M': 1.15,
|
|
'Q3_K': 1.25,
|
|
'Q2_K': 1.35
|
|
};
|
|
|
|
// Backend speed constants (K)
|
|
this.backendK = {
|
|
'metal': 160, // Apple Metal
|
|
'cuda': 220, // NVIDIA CUDA
|
|
'rocm': 220,
|
|
'vulkan': 160,
|
|
'sycl': 160,
|
|
'cpu_x86': 70, // CPU x86_64
|
|
'cpu_arm': 90 // CPU ARM64
|
|
};
|
|
|
|
// Category target speeds (tokens/sec)
|
|
this.targetSpeeds = {
|
|
'general': 40,
|
|
'coding': 40,
|
|
'reasoning': 25,
|
|
'summarization': 60,
|
|
'reading': 60,
|
|
'multimodal': 40,
|
|
'embeddings': 200
|
|
};
|
|
|
|
// Category target contexts
|
|
this.targetContexts = {
|
|
'general': 4096,
|
|
'coding': 8192,
|
|
'reasoning': 8192,
|
|
'summarization': 8192,
|
|
'reading': 8192,
|
|
'multimodal': 4096,
|
|
'embeddings': 512
|
|
};
|
|
|
|
// Category scoring weights [Q, S, F, C] from centralized config
|
|
this.categoryWeights = DETERMINISTIC_WEIGHTS;
|
|
|
|
// User optimization profile overrides [Q, S, F, C]
|
|
this.optimizationProfiles = {
|
|
balanced: null,
|
|
speed: [0.25, 0.55, 0.15, 0.05],
|
|
quality: [0.65, 0.10, 0.15, 0.10],
|
|
context: [0.30, 0.10, 0.20, 0.40],
|
|
coding: [0.55, 0.25, 0.10, 0.10]
|
|
};
|
|
|
|
this.freshnessThresholds = {
|
|
staleDays: 365,
|
|
veryStaleDays: 730,
|
|
indexCadenceDays: 14
|
|
};
|
|
|
|
this.modelIndexStatus = {
|
|
source: 'unknown',
|
|
ageDays: null,
|
|
isStale: false,
|
|
cachedAt: null
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// PHASE 0: DATA SOURCES
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Hardware Profiler - Detect CPU, GPU, RAM, and acceleration support
|
|
*/
|
|
async getHardware() {
|
|
const hardware = {
|
|
cpu: await this.getCPUInfo(),
|
|
gpu: await this.getGPUInfo(),
|
|
memory: await this.getMemoryInfo(),
|
|
os: await this.getOSInfo(),
|
|
acceleration: await this.getAccelerationSupport()
|
|
};
|
|
|
|
// Calculate usable memory: min(0.8 * total_ram, total_ram - 2GB)
|
|
hardware.usableMemGB = Math.min(
|
|
0.8 * hardware.memory.totalGB,
|
|
hardware.memory.totalGB - 2
|
|
);
|
|
|
|
return hardware;
|
|
}
|
|
|
|
/**
|
|
* Normalize hardware shape coming from different detectors/callers.
|
|
* Ensures deterministic selector always has:
|
|
* - memory.totalGB
|
|
* - gpu.vramGB
|
|
* - acceleration.supports_*
|
|
*/
|
|
normalizeHardwareProfile(input = {}, options = {}) {
|
|
const hasExplicitCpuOnly = Object.prototype.hasOwnProperty.call(options, 'cpuOnly');
|
|
const hasInputCpuOnly = Object.prototype.hasOwnProperty.call(input, 'cpuOnly');
|
|
const effectiveCpuOnly = hasExplicitCpuOnly
|
|
? resolveCpuOnlyMode(options.cpuOnly)
|
|
: (hasInputCpuOnly ? resolveCpuOnlyMode(input.cpuOnly) : this.cpuOnly);
|
|
const shouldExposeCpuOnlyMode = hasExplicitCpuOnly || hasInputCpuOnly ||
|
|
this.hasExplicitCpuOnly || effectiveCpuOnly;
|
|
input = applyCpuOnlyOverride(input, {
|
|
cpuOnly: effectiveCpuOnly,
|
|
source: 'deterministic-selector'
|
|
});
|
|
const toNumber = (value) => {
|
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
|
|
return Number(value);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const cpu = input.cpu || {};
|
|
const gpu = input.gpu || {};
|
|
const memory = input.memory || {};
|
|
const acceleration = input.acceleration || {};
|
|
const gpuEntries = Array.isArray(gpu.all) ? gpu.all : [];
|
|
|
|
const totalMemGB =
|
|
toNumber(memory.totalGB) ??
|
|
toNumber(memory.total) ??
|
|
toNumber(input.total_ram_gb) ??
|
|
toNumber(input.memoryGB) ??
|
|
8;
|
|
|
|
const modelHints = `${gpu.model || ''} ${gpu.vendor || ''} ${gpu.type || ''}`.toLowerCase();
|
|
const inferredUnified =
|
|
Boolean(gpu.unified) ||
|
|
/apple|m1|m2|m3|m4|unified/.test(modelHints);
|
|
|
|
const utilizationFactor = inferredUnified ? 0.85 : 0.8;
|
|
const memoryHeadroomGB = inferredUnified ? 1.5 : 2;
|
|
const usableMemGB =
|
|
toNumber(input.usableMemGB) ??
|
|
toNumber(input.summary?.effectiveMemory) ??
|
|
Math.max(1, Math.min(utilizationFactor * totalMemGB, totalMemGB - memoryHeadroomGB));
|
|
|
|
const gpuCount =
|
|
toNumber(gpu.gpuCount) ??
|
|
toNumber(gpu.count) ??
|
|
(gpuEntries.length > 0 ? gpuEntries.length : null) ??
|
|
toNumber(input.gpuCount) ??
|
|
1;
|
|
|
|
const vramPerGPU =
|
|
toNumber(gpu.vramPerGPU) ??
|
|
toNumber(input.vramPerGPU) ??
|
|
null;
|
|
|
|
const summedEntryVRAM = gpuEntries.reduce((sum, entry) => {
|
|
return sum + (
|
|
toNumber(entry?.vramGB) ??
|
|
toNumber(entry?.vram) ??
|
|
toNumber(entry?.totalVRAM) ??
|
|
0
|
|
);
|
|
}, 0);
|
|
|
|
const explicitTotalVRAM =
|
|
toNumber(gpu.totalVRAM) ??
|
|
toNumber(input.totalVRAM) ??
|
|
toNumber(input.gpuTotalVRAM) ??
|
|
(summedEntryVRAM > 0 ? summedEntryVRAM : null);
|
|
|
|
const directVRAM =
|
|
toNumber(gpu.vramGB) ??
|
|
toNumber(gpu.vram) ??
|
|
null;
|
|
|
|
let vramGB =
|
|
explicitTotalVRAM ??
|
|
directVRAM ??
|
|
0;
|
|
|
|
// Multi-GPU: only scale up when memory is known to be PER-GPU (vramPerGPU).
|
|
// A bare `vram`/`vramGB` is treated as the box total and never multiplied,
|
|
// so we don't double an already-total figure and falsely "fit" a model
|
|
// (e.g. a 2x24=48GB box must stay 48GB, not become 96GB).
|
|
if (!explicitTotalVRAM && gpuCount > 1 && vramPerGPU) {
|
|
vramGB = vramPerGPU * gpuCount;
|
|
}
|
|
|
|
let gpuType = gpu.type;
|
|
if (!gpuType) {
|
|
if (inferredUnified) gpuType = 'apple_silicon';
|
|
else if (/nvidia|rtx|gtx|tesla|quadro/.test(modelHints)) gpuType = 'nvidia';
|
|
else if (/amd|radeon|rx |instinct/.test(modelHints)) gpuType = 'amd';
|
|
else gpuType = 'cpu_only';
|
|
}
|
|
|
|
const normalizedAcceleration = {
|
|
...acceleration,
|
|
supports_metal:
|
|
typeof acceleration.supports_metal === 'boolean'
|
|
? acceleration.supports_metal
|
|
: gpuType === 'apple_silicon',
|
|
supports_cuda:
|
|
typeof acceleration.supports_cuda === 'boolean'
|
|
? acceleration.supports_cuda
|
|
: gpuType === 'nvidia',
|
|
supports_rocm:
|
|
typeof acceleration.supports_rocm === 'boolean'
|
|
? acceleration.supports_rocm
|
|
: gpuType === 'amd'
|
|
};
|
|
|
|
return {
|
|
...input,
|
|
...(shouldExposeCpuOnlyMode ? { cpuOnly: effectiveCpuOnly } : {}),
|
|
cpu: {
|
|
...cpu,
|
|
architecture: cpu.architecture || cpu.arch || process.arch || 'x86_64',
|
|
cores: toNumber(cpu.cores) ?? toNumber(cpu.physicalCores) ?? 4
|
|
},
|
|
gpu: {
|
|
...gpu,
|
|
type: gpuType,
|
|
vramGB,
|
|
vramPerGPU: vramPerGPU ?? (gpuCount > 0 ? (vramGB > 0 ? vramGB / gpuCount : 0) : 0),
|
|
gpuCount,
|
|
isMultiGPU: Boolean(gpu.isMultiGPU || gpuCount > 1),
|
|
unified: inferredUnified
|
|
},
|
|
memory: {
|
|
...memory,
|
|
totalGB: totalMemGB
|
|
},
|
|
acceleration: normalizedAcceleration,
|
|
usableMemGB
|
|
};
|
|
}
|
|
|
|
normalizeOptimizationObjective(objective) {
|
|
if (!objective) return 'balanced';
|
|
const normalized = String(objective).toLowerCase().trim();
|
|
if (['balanced', 'default', 'auto'].includes(normalized)) return 'balanced';
|
|
if (['speed', 'fast', 'latency', 'throughput'].includes(normalized)) return 'speed';
|
|
if (['quality', 'accurate', 'accuracy'].includes(normalized)) return 'quality';
|
|
if (['context', 'long-context', 'long_context', 'memory'].includes(normalized)) return 'context';
|
|
if (['coding', 'code', 'developer'].includes(normalized)) return 'coding';
|
|
return 'balanced';
|
|
}
|
|
|
|
getScoringWeights(category, optimizeFor = 'balanced') {
|
|
const base = this.categoryWeights[category] || this.categoryWeights.general;
|
|
const objective = this.normalizeOptimizationObjective(optimizeFor);
|
|
const objectiveWeights = this.optimizationProfiles[objective];
|
|
|
|
if (!objectiveWeights) {
|
|
return base;
|
|
}
|
|
|
|
// Blend category semantics with requested profile, but keep explicit
|
|
// user intent dominant (especially for quality/context priorities).
|
|
const objectivePriorities = {
|
|
speed: 0.8,
|
|
quality: 0.95,
|
|
context: 0.85,
|
|
coding: 0.8
|
|
};
|
|
const objectivePriority = objectivePriorities[objective] || 0.75;
|
|
const categoryPriority = 1 - objectivePriority;
|
|
|
|
return base.map((weight, idx) => {
|
|
const blended = (weight * categoryPriority) + (objectiveWeights[idx] * objectivePriority);
|
|
return Math.round(blended * 1000) / 1000;
|
|
});
|
|
}
|
|
|
|
async getCPUInfo() {
|
|
const os = require('os');
|
|
return {
|
|
architecture: os.arch(),
|
|
cores: os.cpus().length,
|
|
threads: os.cpus().length, // Simplified
|
|
platform: os.platform()
|
|
};
|
|
}
|
|
|
|
async getGPUInfo() {
|
|
const cpu = await this.getCPUInfo();
|
|
|
|
// Simplified GPU detection
|
|
if (cpu.platform === 'darwin' && cpu.architecture === 'arm64') {
|
|
return {
|
|
type: 'apple_silicon',
|
|
vramGB: 0, // Unified memory
|
|
unified: true
|
|
};
|
|
}
|
|
|
|
// TODO: Add NVIDIA/AMD detection for other platforms
|
|
return {
|
|
type: 'cpu_only',
|
|
vramGB: 0,
|
|
unified: false
|
|
};
|
|
}
|
|
|
|
async getMemoryInfo() {
|
|
const os = require('os');
|
|
const totalBytes = os.totalmem();
|
|
return {
|
|
totalGB: Math.round((totalBytes / (1024**3)) * 10) / 10
|
|
};
|
|
}
|
|
|
|
async getOSInfo() {
|
|
const os = require('os');
|
|
return {
|
|
platform: os.platform(),
|
|
arch: os.arch(),
|
|
release: os.release()
|
|
};
|
|
}
|
|
|
|
async getAccelerationSupport() {
|
|
const cpu = await this.getCPUInfo();
|
|
const gpu = await this.getGPUInfo();
|
|
|
|
return {
|
|
supports_metal: gpu.type === 'apple_silicon',
|
|
supports_cuda: gpu.type === 'nvidia',
|
|
supports_rocm: gpu.type === 'amd'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Local Ollama Inventory - Get installed models from `ollama list`
|
|
*/
|
|
async getInstalledModels() {
|
|
try {
|
|
const models = await this.runOllamaCommand(['list']);
|
|
const parsed = [];
|
|
|
|
for (const line of models.split('\n').slice(1)) { // Skip header
|
|
if (!line.trim()) continue;
|
|
|
|
const parts = line.trim().split(/\s+/);
|
|
if (parts.length < 3) continue;
|
|
|
|
const modelName = parts[0];
|
|
const modelId = parts[1];
|
|
const size = parts.length >= 4 ? `${parts[2]} ${parts[3]}` : parts[2];
|
|
|
|
// Get detailed info for each model
|
|
try {
|
|
const details = await this.getModelDetails(modelName);
|
|
parsed.push({
|
|
...details,
|
|
installed: true,
|
|
installedSize: size,
|
|
source: 'ollama_local',
|
|
registry: details.registry || 'ollama.com',
|
|
version: details.version || modelName,
|
|
license: details.license || 'unknown',
|
|
digest: details.digest || 'unknown',
|
|
provenance: {
|
|
...(details.provenance || {}),
|
|
source: 'ollama_local',
|
|
registry: details.registry || 'ollama.com',
|
|
version: details.version || modelName,
|
|
license: details.license || 'unknown',
|
|
digest: details.digest || 'unknown'
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.warn(`Failed to get details for ${modelName}:`, error.message);
|
|
}
|
|
}
|
|
|
|
return parsed;
|
|
} catch (error) {
|
|
// Silently fail when Ollama is not available - this is expected
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async getModelDetails(modelName) {
|
|
try {
|
|
const details = await this.runOllamaCommand(['show', modelName]);
|
|
|
|
// Parse model details from ollama show output
|
|
const meta = {
|
|
name: modelName,
|
|
family: this.extractFamily(modelName),
|
|
paramsB: this.extractParams(details),
|
|
ctxMax: this.extractContextLength(details),
|
|
quant: this.extractQuantization(details),
|
|
sizeGB: this.extractSizeGB(details),
|
|
modalities: this.extractModalities(details),
|
|
tags: this.extractTags(details),
|
|
model_identifier: modelName,
|
|
source: 'ollama_local',
|
|
registry: 'ollama.com',
|
|
version: modelName,
|
|
license: this.extractLicense(details),
|
|
digest: this.extractDigest(details)
|
|
};
|
|
meta.provenance = {
|
|
source: meta.source,
|
|
registry: meta.registry,
|
|
version: meta.version,
|
|
license: meta.license,
|
|
digest: meta.digest
|
|
};
|
|
|
|
return meta;
|
|
} catch (error) {
|
|
// If Ollama is not available or model details can't be fetched, return minimal info
|
|
return {
|
|
name: modelName,
|
|
family: 'unknown',
|
|
paramsB: 0,
|
|
ctxMax: 2048,
|
|
quant: 'unknown',
|
|
sizeGB: 0,
|
|
modalities: ['text'],
|
|
tags: [],
|
|
model_identifier: modelName,
|
|
source: 'ollama_local',
|
|
registry: 'ollama.com',
|
|
version: modelName,
|
|
license: 'unknown',
|
|
digest: 'unknown',
|
|
provenance: {
|
|
source: 'ollama_local',
|
|
registry: 'ollama.com',
|
|
version: modelName,
|
|
license: 'unknown',
|
|
digest: 'unknown'
|
|
},
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Curated Catalog - Load known models from catalog.json
|
|
*/
|
|
async loadCatalog() {
|
|
try {
|
|
if (!fs.existsSync(this.catalogPath)) {
|
|
console.warn('Catalog not found, creating default...');
|
|
await this.createDefaultCatalog();
|
|
}
|
|
|
|
const catalogData = fs.readFileSync(this.catalogPath, 'utf8');
|
|
const catalog = JSON.parse(catalogData);
|
|
|
|
return catalog.models.map(model => ({
|
|
...model,
|
|
installed: false,
|
|
source: model.source || 'static_catalog',
|
|
registry: model.registry || 'ollama.com',
|
|
version: model.version || model.model_identifier || model.name || 'unknown',
|
|
license: model.license || 'unknown',
|
|
digest: model.digest || 'unknown',
|
|
provenance: {
|
|
source: model.source || 'static_catalog',
|
|
registry: model.registry || 'ollama.com',
|
|
version: model.version || model.model_identifier || model.name || 'unknown',
|
|
license: model.license || 'unknown',
|
|
digest: model.digest || 'unknown'
|
|
}
|
|
}));
|
|
} catch (error) {
|
|
console.warn('Failed to load catalog:', error.message);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async createDefaultCatalog() {
|
|
const defaultCatalog = {
|
|
version: "1.0",
|
|
updated: new Date().toISOString(),
|
|
models: [
|
|
{
|
|
name: "qwen2.5-coder:0.5b",
|
|
family: "qwen2.5",
|
|
paramsB: 0.5,
|
|
ctxMax: 32768,
|
|
quant: "Q4_K_M",
|
|
sizeGB: 0.4,
|
|
modalities: ["text"],
|
|
tags: ["coder", "instruct"],
|
|
model_identifier: "qwen2.5-coder:0.5b"
|
|
},
|
|
{
|
|
name: "qwen2.5-coder:1.5b",
|
|
family: "qwen2.5",
|
|
paramsB: 1.5,
|
|
ctxMax: 32768,
|
|
quant: "Q4_K_M",
|
|
sizeGB: 1.1,
|
|
modalities: ["text"],
|
|
tags: ["coder", "instruct"],
|
|
model_identifier: "qwen2.5-coder:1.5b"
|
|
},
|
|
{
|
|
name: "qwen2.5-coder:7b",
|
|
family: "qwen2.5",
|
|
paramsB: 7,
|
|
ctxMax: 32768,
|
|
quant: "Q4_K_M",
|
|
sizeGB: 4.4,
|
|
modalities: ["text"],
|
|
tags: ["coder", "instruct"],
|
|
model_identifier: "qwen2.5-coder:7b"
|
|
},
|
|
{
|
|
name: "llama3.2:3b",
|
|
family: "llama3.2",
|
|
paramsB: 3,
|
|
ctxMax: 131072,
|
|
quant: "Q4_K_M",
|
|
sizeGB: 2.0,
|
|
modalities: ["text"],
|
|
tags: ["instruct", "chat"],
|
|
model_identifier: "llama3.2:3b"
|
|
},
|
|
{
|
|
name: "llava:7b",
|
|
family: "llava",
|
|
paramsB: 7,
|
|
ctxMax: 4096,
|
|
quant: "Q4_K_M",
|
|
sizeGB: 4.7,
|
|
modalities: ["text", "vision"],
|
|
tags: ["multimodal", "vision"],
|
|
model_identifier: "llava:7b"
|
|
}
|
|
]
|
|
};
|
|
|
|
// Ensure directory exists
|
|
const dir = path.dirname(this.catalogPath);
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
fs.writeFileSync(this.catalogPath, JSON.stringify(defaultCatalog, null, 2));
|
|
}
|
|
|
|
/**
|
|
* Full model pool loader:
|
|
* 1) Prefer complete Ollama scraped cache (all families/sizes)
|
|
* 2) Fallback to static curated catalog
|
|
*/
|
|
async loadModelPool() {
|
|
const cacheModels = await this.loadOllamaCacheModels();
|
|
if (cacheModels.length > 0) {
|
|
return cacheModels;
|
|
}
|
|
return this.loadCatalog();
|
|
}
|
|
|
|
async loadOllamaCacheModels() {
|
|
for (const cachePath of this.ollamaCachePaths) {
|
|
try {
|
|
if (!fs.existsSync(cachePath)) continue;
|
|
const raw = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
|
const sourceModels = Array.isArray(raw) ? raw : (raw.models || []);
|
|
const indexMeta = this.extractModelIndexMetadata(raw, cachePath);
|
|
const normalized = this.normalizeExternalModels(sourceModels, { indexMeta });
|
|
if (normalized.length > 0) return normalized;
|
|
} catch (error) {
|
|
// Ignore broken cache files and keep trying fallbacks
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
extractModelIndexMetadata(raw, sourcePath = '') {
|
|
const cachedAtRaw = raw?.cached_at || raw?.generated_at || raw?.last_updated || null;
|
|
const cachedAt = this.parseDateSafe(cachedAtRaw);
|
|
const ageDays = cachedAt
|
|
? Math.max(0, (Date.now() - cachedAt.getTime()) / (1000 * 60 * 60 * 24))
|
|
: null;
|
|
const isStale = Number.isFinite(ageDays) && ageDays > this.freshnessThresholds.indexCadenceDays;
|
|
|
|
const status = {
|
|
source: sourcePath || 'cache',
|
|
ageDays: Number.isFinite(ageDays) ? Math.round(ageDays * 10) / 10 : null,
|
|
isStale: Boolean(isStale),
|
|
cachedAt: cachedAt ? cachedAt.toISOString() : null
|
|
};
|
|
|
|
this.modelIndexStatus = status;
|
|
return status;
|
|
}
|
|
|
|
normalizeExternalModels(models = [], context = {}) {
|
|
const normalized = [];
|
|
const indexMeta = context.indexMeta || this.modelIndexStatus || {};
|
|
|
|
for (const model of models) {
|
|
if (!model || typeof model !== 'object') continue;
|
|
|
|
const alreadyNormalized =
|
|
typeof model.paramsB === 'number' &&
|
|
typeof model.ctxMax === 'number' &&
|
|
model.model_identifier;
|
|
|
|
const freshness = this.computeFreshnessMetadata(model, indexMeta);
|
|
const quantizations = this.extractAvailableQuantizations(model, model.variants || []);
|
|
|
|
if (alreadyNormalized) {
|
|
normalized.push({
|
|
...model,
|
|
tags: Array.isArray(model.tags) ? model.tags : [],
|
|
modalities: Array.isArray(model.modalities) ? model.modalities : ['text'],
|
|
installed: Boolean(model.installed),
|
|
availableQuantizations: model.availableQuantizations || quantizations,
|
|
sizeByQuant: model.sizeByQuant || {},
|
|
source: model.source || 'ollama_database',
|
|
registry: model.registry || 'ollama.com',
|
|
version: model.version || model.model_identifier,
|
|
license: model.license || 'unknown',
|
|
digest: model.digest || 'unknown',
|
|
...freshness,
|
|
provenance: model.provenance || {
|
|
source: model.source || 'ollama_database',
|
|
registry: model.registry || 'ollama.com',
|
|
version: model.version || model.model_identifier,
|
|
license: model.license || 'unknown',
|
|
digest: model.digest || 'unknown'
|
|
}
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const converted = this.convertOllamaModelToDeterministicModels(model, { indexMeta });
|
|
normalized.push(...converted);
|
|
}
|
|
|
|
const deduped = new Map();
|
|
for (const model of normalized) {
|
|
const key = model.model_identifier || model.name;
|
|
if (!key || deduped.has(key)) continue;
|
|
deduped.set(key, model);
|
|
}
|
|
|
|
return [...deduped.values()];
|
|
}
|
|
|
|
convertOllamaModelToDeterministicModels(ollamaModel, context = {}) {
|
|
const baseIdentifier = ollamaModel.model_identifier || ollamaModel.model_name || 'unknown';
|
|
const fallbackTag = `${baseIdentifier}:latest`;
|
|
const variants = Array.isArray(ollamaModel.variants) && ollamaModel.variants.length > 0
|
|
? ollamaModel.variants
|
|
: [{ tag: ollamaModel.model_identifier || fallbackTag }];
|
|
const indexMeta = context.indexMeta || this.modelIndexStatus || {};
|
|
const freshness = this.computeFreshnessMetadata(ollamaModel, indexMeta);
|
|
|
|
const contextLength = this.parseContextLength(
|
|
ollamaModel.context_length ||
|
|
ollamaModel.contextLength ||
|
|
ollamaModel.ctxMax
|
|
);
|
|
|
|
const baseText = [
|
|
ollamaModel.model_identifier,
|
|
ollamaModel.model_name,
|
|
ollamaModel.description,
|
|
ollamaModel.detailed_description,
|
|
ollamaModel.primary_category,
|
|
...(Array.isArray(ollamaModel.use_cases) ? ollamaModel.use_cases : []),
|
|
...(Array.isArray(ollamaModel.categories) ? ollamaModel.categories : [])
|
|
].filter(Boolean).join(' ').toLowerCase();
|
|
|
|
const derivedTags = new Set();
|
|
if (baseText.includes('code') || baseText.includes('coder')) derivedTags.add('coder');
|
|
if (baseText.includes('instruct')) derivedTags.add('instruct');
|
|
if (baseText.includes('chat') || baseText.includes('assistant') || baseText.includes('conversation')) derivedTags.add('chat');
|
|
if (baseText.includes('embed')) derivedTags.add('embedding');
|
|
if (baseText.includes('vision') || baseText.includes('vl') || baseText.includes('multimodal') || baseText.includes('image')) derivedTags.add('vision');
|
|
if (baseText.includes('reason') || baseText.includes('math') || baseText.includes('logic')) derivedTags.add('reasoning');
|
|
if (baseText.includes('creative') || baseText.includes('story') || baseText.includes('roleplay')) derivedTags.add('creative');
|
|
|
|
if (ollamaModel.primary_category === 'coding') derivedTags.add('coder');
|
|
if (ollamaModel.primary_category === 'chat') derivedTags.add('chat');
|
|
if (ollamaModel.primary_category === 'embeddings') derivedTags.add('embedding');
|
|
if (ollamaModel.primary_category === 'multimodal') derivedTags.add('vision');
|
|
if (ollamaModel.primary_category === 'reasoning') derivedTags.add('reasoning');
|
|
if (ollamaModel.primary_category === 'creative') derivedTags.add('creative');
|
|
|
|
const hasConcreteVariants = variants.some((variant) => this.variantHasConcreteSizeOrParams(variant));
|
|
const selectableVariants = hasConcreteVariants
|
|
? variants.filter((variant) => this.variantHasConcreteSizeOrParams(variant))
|
|
: variants;
|
|
|
|
return selectableVariants
|
|
.map((variant) => {
|
|
const variantTag = variant.tag || fallbackTag;
|
|
const quant = this.resolveVariantQuantization(variant, variantTag);
|
|
const paramsB = this.resolveVariantParamsB(ollamaModel, variant, quant);
|
|
const moeMetadata = this.extractMoEMetadata(ollamaModel, variant, paramsB, baseText);
|
|
|
|
const variantSizeGB = this.extractVariantSizeGB(variant, paramsB);
|
|
const modalities = this.inferModalities(ollamaModel, variantTag);
|
|
const modelTags = this.inferTagsForVariant(derivedTags, variant, variantTag);
|
|
const sizeByQuant = {};
|
|
|
|
if (Number.isFinite(variantSizeGB) && variantSizeGB > 0) sizeByQuant[quant] = variantSizeGB;
|
|
const availableQuantizations = [quant];
|
|
|
|
const source = ollamaModel.source || 'ollama_database';
|
|
const registry = ollamaModel.registry || 'ollama.com';
|
|
const version = ollamaModel.version || variantTag;
|
|
const license = ollamaModel.license || 'unknown';
|
|
const digest = ollamaModel.digest || 'unknown';
|
|
const normalizedExpertCount = Number.isFinite(moeMetadata.expertCount) && moeMetadata.expertCount > 0
|
|
? Math.round(moeMetadata.expertCount)
|
|
: null;
|
|
const normalizedExpertsActive = Number.isFinite(moeMetadata.expertsActivePerToken) && moeMetadata.expertsActivePerToken > 0
|
|
? moeMetadata.expertsActivePerToken
|
|
: null;
|
|
const normalizedTotalParamsB = Number.isFinite(moeMetadata.totalParamsB) && moeMetadata.totalParamsB > 0
|
|
? moeMetadata.totalParamsB
|
|
: null;
|
|
const normalizedActiveParamsB = Number.isFinite(moeMetadata.activeParamsB) && moeMetadata.activeParamsB > 0
|
|
? moeMetadata.activeParamsB
|
|
: null;
|
|
|
|
return {
|
|
name: variantTag,
|
|
model_name: ollamaModel.model_name || baseIdentifier,
|
|
family: this.extractFamily(baseIdentifier),
|
|
paramsB,
|
|
isMoE: Boolean(moeMetadata.isMoE),
|
|
is_moe: Boolean(moeMetadata.isMoE),
|
|
totalParamsB: normalizedTotalParamsB,
|
|
activeParamsB: normalizedActiveParamsB,
|
|
expertCount: normalizedExpertCount,
|
|
expertsActivePerToken: normalizedExpertsActive,
|
|
total_params_b: normalizedTotalParamsB,
|
|
active_params_b: normalizedActiveParamsB,
|
|
expert_count: normalizedExpertCount,
|
|
experts_active_per_token: normalizedExpertsActive,
|
|
ctxMax: contextLength,
|
|
quant,
|
|
sizeGB: variantSizeGB,
|
|
modalities,
|
|
tags: modelTags,
|
|
capabilities: [ollamaModel.primary_category, ...['capabilities', 'categories', 'use_cases']
|
|
.flatMap(key => Array.isArray(ollamaModel[key]) ? ollamaModel[key] : [])].filter(Boolean),
|
|
sourceTags: Array.isArray(ollamaModel.tags) ? ollamaModel.tags : [],
|
|
description: ollamaModel.description || '',
|
|
detailed_description: ollamaModel.detailed_description || '',
|
|
model_identifier: variantTag,
|
|
last_updated: ollamaModel.last_updated || ollamaModel.lastUpdated || '',
|
|
updated_at: ollamaModel.updated_at || ollamaModel.updatedAt || '',
|
|
installed: false,
|
|
pulls: ollamaModel.actual_pulls || ollamaModel.pulls || 0,
|
|
availableQuantizations,
|
|
sizeByQuant,
|
|
...freshness,
|
|
source,
|
|
registry,
|
|
version,
|
|
license,
|
|
digest,
|
|
provenance: {
|
|
source,
|
|
registry,
|
|
version,
|
|
license,
|
|
digest
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
variantHasConcreteSizeOrParams(variant = {}) {
|
|
const params = this.extractParamsFromString(
|
|
variant.params_b,
|
|
variant.paramsB,
|
|
variant.parameter_size,
|
|
variant.size,
|
|
variant.tag,
|
|
variant.label,
|
|
variant.name
|
|
);
|
|
if (Number.isFinite(params) && params > 0) return true;
|
|
|
|
const artifactSize = Number(
|
|
variant.real_size_gb ??
|
|
variant.estimated_size_gb ??
|
|
variant.size_gb ??
|
|
NaN
|
|
);
|
|
|
|
return Number.isFinite(artifactSize) && artifactSize > 0;
|
|
}
|
|
|
|
parseBillionsValue(rawValue) {
|
|
return parseMoEBillionsValue(rawValue);
|
|
}
|
|
|
|
parsePositiveNumber(rawValue) {
|
|
return parseMoEPositiveNumber(rawValue);
|
|
}
|
|
|
|
extractMoEMetadata(model = {}, variant = {}, paramsB = null, baseText = '') {
|
|
return extractCanonicalMoEMetadata({
|
|
model,
|
|
variant,
|
|
paramsB,
|
|
baseText
|
|
});
|
|
}
|
|
|
|
parseDateSafe(value) {
|
|
if (!value || typeof value !== 'string') return null;
|
|
const normalized = value.trim();
|
|
const relativeMatch = normalized.match(/^(\d+)\s*(minutes?|hours?|days?|weeks?|months?|years?)\s+ago$/i);
|
|
if (relativeMatch) {
|
|
const amount = parseInt(relativeMatch[1], 10);
|
|
const unit = relativeMatch[2].toLowerCase();
|
|
const days =
|
|
unit.startsWith('minute') ? amount / (24 * 60) :
|
|
unit.startsWith('hour') ? amount / 24 :
|
|
unit.startsWith('day') ? amount :
|
|
unit.startsWith('week') ? amount * 7 :
|
|
unit.startsWith('month') ? amount * 30 :
|
|
unit.startsWith('year') ? amount * 365 :
|
|
null;
|
|
|
|
if (Number.isFinite(days)) {
|
|
return new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
}
|
|
}
|
|
|
|
const parsed = new Date(normalized);
|
|
if (Number.isNaN(parsed.getTime())) return null;
|
|
return parsed;
|
|
}
|
|
|
|
extractAvailableQuantizations(model, variants = []) {
|
|
const quantSet = new Set();
|
|
const candidateStrings = [];
|
|
|
|
if (Array.isArray(model?.quantizations)) {
|
|
candidateStrings.push(...model.quantizations);
|
|
}
|
|
if (typeof model?.quantization === 'string') {
|
|
candidateStrings.push(model.quantization);
|
|
}
|
|
for (const variant of variants) {
|
|
if (variant?.quantization) candidateStrings.push(variant.quantization);
|
|
if (variant?.tag) candidateStrings.push(variant.tag);
|
|
}
|
|
|
|
for (const value of candidateStrings) {
|
|
const inferred = this.normalizeQuantization(
|
|
this.extractQuantizationFromTag(String(value)) || String(value)
|
|
);
|
|
if (inferred) quantSet.add(inferred);
|
|
}
|
|
|
|
if (quantSet.size === 0 && model?.quant) {
|
|
quantSet.add(this.normalizeQuantization(model.quant));
|
|
}
|
|
if (quantSet.size === 0) {
|
|
quantSet.add('Q4_K_M');
|
|
}
|
|
|
|
return [...quantSet].sort((a, b) => {
|
|
const aIdx = this.quantHierarchy.indexOf(a);
|
|
const bIdx = this.quantHierarchy.indexOf(b);
|
|
const safeA = aIdx === -1 ? Number.MAX_SAFE_INTEGER : aIdx;
|
|
const safeB = bIdx === -1 ? Number.MAX_SAFE_INTEGER : bIdx;
|
|
return safeA - safeB;
|
|
});
|
|
}
|
|
|
|
computeFreshnessMetadata(model = {}, indexMeta = {}) {
|
|
const dateCandidates = [
|
|
model.last_updated,
|
|
model.lastUpdated,
|
|
model.updated_at,
|
|
model.updatedAt,
|
|
model.release_date,
|
|
model.released_at,
|
|
model.created_at
|
|
];
|
|
|
|
const updatedAt = dateCandidates
|
|
.map((value) => this.parseDateSafe(value))
|
|
.find(Boolean);
|
|
|
|
const ageDays = updatedAt
|
|
? Math.max(0, (Date.now() - updatedAt.getTime()) / (1000 * 60 * 60 * 24))
|
|
: null;
|
|
|
|
let freshnessScore = 55; // neutral fallback when timestamp is unknown
|
|
if (Number.isFinite(ageDays)) {
|
|
if (ageDays <= 30) freshnessScore = 100;
|
|
else if (ageDays <= 90) freshnessScore = 90;
|
|
else if (ageDays <= 180) freshnessScore = 75;
|
|
else if (ageDays <= 365) freshnessScore = 60;
|
|
else if (ageDays <= 540) freshnessScore = 40;
|
|
else if (ageDays <= 720) freshnessScore = 25;
|
|
else freshnessScore = 10;
|
|
}
|
|
|
|
const textBlob = [
|
|
model.model_identifier,
|
|
model.model_name,
|
|
model.name,
|
|
model.description,
|
|
model.detailed_description,
|
|
model.status,
|
|
...(Array.isArray(model.tags) ? model.tags : [])
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
.toLowerCase();
|
|
|
|
const isDeprecatedByText =
|
|
/\bdeprecated\b|\bobsolete\b|\blegacy\b|\barchived\b|\breplaced by\b|\buse .+ instead\b/.test(textBlob);
|
|
const isDeprecated = Boolean(model.deprecated || model.is_deprecated || model.archived || isDeprecatedByText);
|
|
const isStale = Number.isFinite(ageDays) && ageDays > this.freshnessThresholds.staleDays;
|
|
const veryStale = Number.isFinite(ageDays) && ageDays > this.freshnessThresholds.veryStaleDays;
|
|
const indexStale = Boolean(indexMeta?.isStale);
|
|
|
|
if (isDeprecated) freshnessScore = Math.min(freshnessScore, 15);
|
|
if (veryStale) freshnessScore = Math.min(freshnessScore, 20);
|
|
if (indexStale && !updatedAt) freshnessScore = Math.max(0, freshnessScore - 10);
|
|
|
|
return {
|
|
lastUpdatedAt: updatedAt ? updatedAt.toISOString() : null,
|
|
modelAgeDays: Number.isFinite(ageDays) ? Math.round(ageDays * 10) / 10 : null,
|
|
freshnessScore,
|
|
isStale,
|
|
isDeprecated,
|
|
indexAgeDays: Number.isFinite(indexMeta?.ageDays) ? indexMeta.ageDays : null,
|
|
indexStale
|
|
};
|
|
}
|
|
|
|
parseContextLength(contextValue) {
|
|
if (typeof contextValue === 'number' && Number.isFinite(contextValue) && contextValue > 0) {
|
|
return Math.round(contextValue);
|
|
}
|
|
|
|
if (typeof contextValue === 'string') {
|
|
const match = contextValue.match(/(\d+\.?\d*)\s*([KkMm]?)/);
|
|
if (match) {
|
|
const value = parseFloat(match[1]);
|
|
const unit = (match[2] || '').toUpperCase();
|
|
if (unit === 'M') return Math.round(value * 1024 * 1024);
|
|
if (unit === 'K') return Math.round(value * 1024);
|
|
return Math.round(value);
|
|
}
|
|
}
|
|
|
|
return 4096;
|
|
}
|
|
|
|
extractParamsFromString(...values) {
|
|
const candidates = this.extractParameterCandidates(...values);
|
|
return candidates.length > 0 ? candidates[0] : null;
|
|
}
|
|
|
|
extractParameterCandidates(...values) {
|
|
const candidates = [];
|
|
const seen = new Set();
|
|
|
|
const pushCandidate = (value) => {
|
|
if (!Number.isFinite(value) || value <= 0) return;
|
|
const rounded = Math.round(value * 1000) / 1000;
|
|
const key = String(rounded);
|
|
if (seen.has(key)) return;
|
|
seen.add(key);
|
|
candidates.push(rounded);
|
|
};
|
|
|
|
const visit = (value) => {
|
|
if (typeof value === 'number') {
|
|
pushCandidate(value);
|
|
return;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
value.forEach(visit);
|
|
return;
|
|
}
|
|
|
|
if (value && typeof value === 'object') {
|
|
Object.values(value).forEach(visit);
|
|
return;
|
|
}
|
|
|
|
if (typeof value !== 'string') return;
|
|
|
|
const regex = /(\d+\.?\d*)\s*([BbMm])/g;
|
|
for (const match of value.matchAll(regex)) {
|
|
const suffix = value.slice(match.index + match[0].length, match.index + match[0].length + 2);
|
|
if (/^\s*b\b/i.test(suffix) || /^\s*[gk]b\b/i.test(suffix)) continue;
|
|
|
|
const amount = parseFloat(match[1]);
|
|
const unit = match[2].toUpperCase();
|
|
pushCandidate(unit === 'M' ? amount / 1000 : amount);
|
|
}
|
|
};
|
|
|
|
values.forEach(visit);
|
|
return candidates;
|
|
}
|
|
|
|
extractArtifactSizeGBFromValue(value) {
|
|
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
return value;
|
|
}
|
|
if (typeof value !== 'string') return null;
|
|
|
|
const match = value.match(/(\d+\.?\d*)\s*g(?:i)?b\b/i);
|
|
if (!match) return null;
|
|
return parseFloat(match[1]);
|
|
}
|
|
|
|
inferParamsFromArtifactSizeGB(sizeGB, quant = 'Q4_K_M') {
|
|
const normalizedQuant = this.normalizeQuantization(quant);
|
|
const bpp = precisionProfile(normalizedQuant).bytes || 2;
|
|
const inferred = sizeGB / bpp;
|
|
return Math.max(0.5, Math.round(inferred * 2) / 2);
|
|
}
|
|
|
|
isCloudVariantTag(tag = '') {
|
|
return /:cloud$/i.test(String(tag).trim());
|
|
}
|
|
|
|
resolveVariantQuantization(variant = {}, variantTag = '') {
|
|
const tagQuant = this.extractQuantizationFromTag(variantTag);
|
|
if (tagQuant) {
|
|
const declared = this.normalizeQuantization(variant.quantization || variant.quant);
|
|
if (/^Q[2-8]$/.test(tagQuant) && declared.startsWith(`${tagQuant}_`)) return declared;
|
|
return this.normalizeQuantization(tagQuant);
|
|
}
|
|
|
|
return this.normalizeQuantization(
|
|
variant.quantization ||
|
|
variant.quant ||
|
|
'UNKNOWN'
|
|
);
|
|
}
|
|
|
|
resolveVariantParamsB(ollamaModel = {}, variant = {}, quant = 'Q4_K_M') {
|
|
const explicitParams = this.extractParamsFromString(
|
|
variant.size,
|
|
variant.tag,
|
|
variant.label,
|
|
variant.name,
|
|
ollamaModel.model_identifier,
|
|
ollamaModel.model_name,
|
|
ollamaModel.parameter_size,
|
|
ollamaModel.parameter_count,
|
|
ollamaModel.parameters
|
|
);
|
|
if (Number.isFinite(explicitParams) && explicitParams > 0) {
|
|
return explicitParams;
|
|
}
|
|
|
|
// Use the variant's OWN artifact size to DISAMBIGUATE the model-level size
|
|
// list. A size-unknown variant (e.g. `:latest`) must not blindly inherit
|
|
// model_sizes[0]: for qwen3 (model_sizes ["30b","235b"]) that mislabeled a
|
|
// small qwen3:latest as 30B and poisoned the real qwen3:30b size map, making
|
|
// a 19GB model falsely "fit" a 16GB machine.
|
|
const artifactSizeGB = this.extractVariantSizeGB(variant, null);
|
|
const artifactParamsB =
|
|
(!this.isCloudVariantTag(variant.tag) && Number.isFinite(artifactSizeGB) && artifactSizeGB > 0)
|
|
? this.inferParamsFromArtifactSizeGB(artifactSizeGB, quant)
|
|
: null;
|
|
|
|
const metadataCandidates = this.extractParameterCandidates(
|
|
ollamaModel.model_sizes,
|
|
ollamaModel.parameters,
|
|
ollamaModel.parameter_size,
|
|
ollamaModel.parameter_count
|
|
);
|
|
if (metadataCandidates.length > 0) {
|
|
if (Number.isFinite(artifactParamsB) && artifactParamsB > 0) {
|
|
// Pick the listed size CLOSEST to what this variant's own artifact
|
|
// implies; if even the closest is far off, trust the artifact size.
|
|
let closest = metadataCandidates[0];
|
|
let bestDiff = Math.abs(closest - artifactParamsB);
|
|
for (const cand of metadataCandidates) {
|
|
const diff = Math.abs(cand - artifactParamsB);
|
|
if (diff < bestDiff) { bestDiff = diff; closest = cand; }
|
|
}
|
|
const tolerance = Math.max(2, closest * 0.5);
|
|
return bestDiff <= tolerance ? closest : artifactParamsB;
|
|
}
|
|
return metadataCandidates[0];
|
|
}
|
|
|
|
if (Number.isFinite(artifactParamsB) && artifactParamsB > 0) {
|
|
return artifactParamsB;
|
|
}
|
|
|
|
const modelArtifactSizeGB = this.extractArtifactSizeGBFromValue(ollamaModel.main_size);
|
|
if (Number.isFinite(modelArtifactSizeGB) && modelArtifactSizeGB > 0) {
|
|
return this.inferParamsFromArtifactSizeGB(modelArtifactSizeGB, quant);
|
|
}
|
|
|
|
return 7;
|
|
}
|
|
|
|
extractQuantizationFromTag(tag = '') {
|
|
const match = String(tag).match(/\b((?:i?q\d+[_\w]*|bf16|fp(?:16|32|8)|f(?:16|32)))\b/i);
|
|
return match ? match[1].toUpperCase() : null;
|
|
}
|
|
|
|
normalizeQuantization(quant) {
|
|
return normalizePrecision(quant);
|
|
}
|
|
|
|
extractVariantSizeGB(variant, paramsB) {
|
|
const candidate = Number(variant.real_size_gb ?? variant.size_gb ?? NaN);
|
|
if (Number.isFinite(candidate) && candidate > 0) return candidate;
|
|
return null; // No observed artifact size; estimate from the actual precision later.
|
|
}
|
|
|
|
inferModalities(model, variantTag = '') {
|
|
const inputTypes = Array.isArray(model.input_types) ? model.input_types.map((x) => String(x).toLowerCase()) : [];
|
|
const primaryCategory = String(model.primary_category || '').toLowerCase();
|
|
const categories = Array.isArray(model.categories) ? model.categories.map((x) => String(x).toLowerCase()) : [];
|
|
const useCases = Array.isArray(model.use_cases) ? model.use_cases.map((x) => String(x).toLowerCase()) : [];
|
|
const text = [
|
|
model.model_identifier,
|
|
model.model_name,
|
|
model.description,
|
|
model.detailed_description,
|
|
variantTag
|
|
].filter(Boolean).join(' ').toLowerCase();
|
|
|
|
const hasVisionInputFlag = inputTypes.includes('image') || inputTypes.includes('vision');
|
|
const hasVisionMetadataHint =
|
|
primaryCategory === 'multimodal' ||
|
|
categories.some((cat) => cat.includes('multimodal') || cat.includes('vision')) ||
|
|
useCases.some((useCase) => useCase.includes('multimodal') || useCase.includes('vision'));
|
|
const hasVisionTextHint =
|
|
/(?:\bmultimodal\b|\bvision\b|\bllava\b|\bbakllava\b|\bmoondream\b|\bpixtral\b|\bidefics\b|\bpaligemma\b|\bminicpm-?v\b|\bqwen[\w.-]*vl\b|\bllama3\.2[-_ ]?vision\b|\bdeepseek-ocr\b)/.test(text);
|
|
const hasVisionContextHint =
|
|
/\b(image[- ]?(understanding|caption|analysis)|vision[- ]?language|vlm)\b/.test(text);
|
|
|
|
// Some upstream scrapers may over-report `image` support by scanning generic page text.
|
|
// Trust image input flags only when accompanied by multimodal metadata or explicit vision naming.
|
|
const hasVision = hasVisionTextHint || hasVisionMetadataHint || (hasVisionInputFlag && hasVisionContextHint);
|
|
|
|
return hasVision ? ['text', 'vision'] : ['text'];
|
|
}
|
|
|
|
inferTagsForVariant(baseTags, variant, variantTag = '') {
|
|
const tags = new Set(baseTags);
|
|
|
|
if (Array.isArray(variant.categories)) {
|
|
for (const cat of variant.categories) {
|
|
const c = String(cat).toLowerCase();
|
|
if (c.includes('code')) tags.add('coder');
|
|
if (c.includes('chat')) tags.add('chat');
|
|
if (c.includes('embed')) tags.add('embedding');
|
|
if (c.includes('vision') || c.includes('multimodal')) tags.add('vision');
|
|
if (c.includes('reason')) tags.add('reasoning');
|
|
if (c.includes('creative')) tags.add('creative');
|
|
}
|
|
}
|
|
|
|
const lowerTag = String(variantTag).toLowerCase();
|
|
if (lowerTag.includes('code') || lowerTag.includes('coder')) tags.add('coder');
|
|
if (lowerTag.includes('instruct')) tags.add('instruct');
|
|
if (lowerTag.includes('chat')) tags.add('chat');
|
|
if (lowerTag.includes('embed')) tags.add('embedding');
|
|
if (lowerTag.includes('vision') || lowerTag.includes('vl')) tags.add('vision');
|
|
if (lowerTag.includes('reason') || lowerTag.includes('math')) tags.add('reasoning');
|
|
|
|
return [...tags];
|
|
}
|
|
|
|
// ============================================================================
|
|
// HELPER METHODS FOR PARSING OLLAMA OUTPUT
|
|
// ============================================================================
|
|
|
|
extractFamily(modelName) {
|
|
const name = modelName.toLowerCase();
|
|
if (name.includes('qwen2.5')) return 'qwen2.5';
|
|
if (name.includes('qwen3')) return 'qwen3';
|
|
if (name.includes('qwen')) return 'qwen2.5';
|
|
if (name.includes('deepseek-r1')) return 'deepseek-r1';
|
|
if (name.includes('deepseek-coder')) return 'deepseek-coder';
|
|
if (name.includes('deepseek')) return 'deepseek';
|
|
if (name.includes('llama3.2') || name.includes('llama3.3')) return 'llama3.2';
|
|
if (name.includes('llama3.1')) return 'llama3.1';
|
|
if (name.includes('llama')) return 'llama';
|
|
if (name.includes('mistral')) return 'mistral';
|
|
if (name.includes('gemma3')) return 'gemma3';
|
|
if (name.includes('gemma')) return 'gemma2';
|
|
if (name.includes('phi')) return 'phi-3';
|
|
if (name.includes('llava')) return 'llava';
|
|
if (name.includes('granite')) return 'granite';
|
|
if (name.includes('solar')) return 'solar';
|
|
if (name.includes('starcoder')) return 'starcoder';
|
|
if (name.includes('minicpm')) return 'minicpm';
|
|
if (name.includes('yi-coder')) return 'yi-coder';
|
|
if (name.includes('yi')) return 'yi';
|
|
return 'unknown';
|
|
}
|
|
|
|
extractParams(details) {
|
|
// Look for parameter info in ollama show output
|
|
const match = details.match(/parameters\s+(\d+\.?\d*)\s*[BM]/i);
|
|
if (match) {
|
|
const num = parseFloat(match[1]);
|
|
return match[0].toUpperCase().includes('B') ? num : num / 1000;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
extractContextLength(details) {
|
|
const match = details.match(/context[ _]length\s+(\d+)/i);
|
|
return match ? parseInt(match[1]) : null;
|
|
}
|
|
|
|
extractQuantization(details) {
|
|
const match = details.match(/quantization\s+(\S+)/i);
|
|
return normalizePrecision(match?.[1]);
|
|
}
|
|
|
|
extractSizeGB(details) {
|
|
const match = details.match(/size\s+(\d+\.?\d*)\s*(GB|MB)/i);
|
|
return match ? parseFloat(match[1]) / (match[2].toUpperCase() === 'MB' ? 1024 : 1) : null;
|
|
}
|
|
|
|
extractModalities(details) {
|
|
const modalities = ['text'];
|
|
if (details.toLowerCase().includes('vision') || details.toLowerCase().includes('image')) {
|
|
modalities.push('vision');
|
|
}
|
|
return modalities;
|
|
}
|
|
|
|
extractTags(details) {
|
|
const tags = [];
|
|
const lowerDetails = details.toLowerCase();
|
|
|
|
if (lowerDetails.includes('instruct')) tags.push('instruct');
|
|
if (lowerDetails.includes('chat')) tags.push('chat');
|
|
if (lowerDetails.includes('code')) tags.push('coder');
|
|
if (lowerDetails.includes('vision')) tags.push('vision');
|
|
// Only mark as embedding if it's explicitly an embedding model
|
|
if (lowerDetails.includes('embed-text') ||
|
|
lowerDetails.includes('nomic-embed') ||
|
|
lowerDetails.includes('bge-') ||
|
|
lowerDetails.includes('all-minilm')) tags.push('embedding');
|
|
|
|
return tags;
|
|
}
|
|
|
|
extractLicense(details) {
|
|
const match = details.match(/license\s+([^\n\r]+)/i);
|
|
return match ? match[1].trim().toLowerCase() : 'unknown';
|
|
}
|
|
|
|
extractDigest(details) {
|
|
const match = details.match(/digest\s+([a-f0-9:]+)/i);
|
|
return match ? match[1].trim().toLowerCase() : 'unknown';
|
|
}
|
|
|
|
async runOllamaCommand(args) {
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
const proc = spawn('ollama', args, { stdio: 'pipe' });
|
|
let output = '';
|
|
let error = '';
|
|
|
|
proc.stdout.on('data', (data) => output += data);
|
|
proc.stderr.on('data', (data) => error += data);
|
|
|
|
proc.on('close', (code) => {
|
|
if (code === 0) {
|
|
resolve(output);
|
|
} else {
|
|
reject(new Error(`Ollama command failed: ${error}`));
|
|
}
|
|
});
|
|
|
|
proc.on('error', (err) => {
|
|
// Handle ENOENT and other spawn errors gracefully
|
|
if (err.code === 'ENOENT') {
|
|
reject(new Error('Ollama not found. Please install Ollama from https://ollama.ai'));
|
|
} else {
|
|
reject(new Error(`Ollama spawn error: ${err.message}`));
|
|
}
|
|
});
|
|
} catch (spawnError) {
|
|
// Handle synchronous spawn errors
|
|
reject(new Error(`Failed to start Ollama: ${spawnError.message}`));
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// PHASE 1: ESTIMATION FILTER
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Main model selection function
|
|
*/
|
|
async selectModels(category = 'general', options = {}) {
|
|
const {
|
|
targetCtx = this.targetContexts[category] || this.targetContexts.general,
|
|
topN = 5,
|
|
enableProbe = false,
|
|
silent = false,
|
|
optimizeFor = 'balanced',
|
|
runtime = 'ollama',
|
|
hardware: providedHardware = null,
|
|
installedModels = null,
|
|
modelPool = null,
|
|
includeUncensored = false
|
|
} = options;
|
|
const normalizedRuntime = normalizeMoERuntime(runtime);
|
|
const optimizationObjective = this.normalizeOptimizationObjective(
|
|
options.optimize || options.objective || optimizeFor
|
|
);
|
|
|
|
if (!silent) {
|
|
console.log(`🔍 Selecting models for category: ${category}`);
|
|
if (optimizationObjective !== 'balanced') {
|
|
console.log(`⚙️ Optimization profile: ${optimizationObjective}`);
|
|
}
|
|
}
|
|
|
|
// Phase 0: Gather data
|
|
const rawHardware = providedHardware || await this.getHardware();
|
|
const hardware = this.normalizeHardwareProfile(rawHardware, {
|
|
...(Object.prototype.hasOwnProperty.call(options, 'cpuOnly')
|
|
? { cpuOnly: options.cpuOnly }
|
|
: {})
|
|
});
|
|
this.activeCpuOnly = Boolean(hardware.cpuOnly);
|
|
if (typeof this.ollamaClient.setCpuOnly === 'function') {
|
|
this.ollamaClient.setCpuOnly(this.activeCpuOnly);
|
|
}
|
|
const installed = Array.isArray(installedModels) ? installedModels : await this.getInstalledModels();
|
|
const externalPool = Array.isArray(modelPool) && modelPool.length > 0
|
|
? (modelPool.some(model => typeof model?.paramsB === 'number' && model?.model_identifier)
|
|
? modelPool
|
|
: this.normalizeExternalModels(modelPool))
|
|
: await this.loadModelPool();
|
|
|
|
if (!silent) {
|
|
const memoryGB = hardware?.memory?.totalGB ?? hardware?.memory?.total ?? 0;
|
|
console.log(`Found ${installed.length} installed, ${externalPool.length} available models`);
|
|
console.log(`Hardware: ${hardware.cpu.cores} cores, ${memoryGB}GB RAM, ${hardware.gpu.type}`);
|
|
}
|
|
|
|
// Combine and dedupe models (prefer installed versions)
|
|
const pool = this.combineModels(installed, externalPool);
|
|
const filtered = this.filterByCategory(pool, category, {
|
|
includeUncensored,
|
|
referenceModels: externalPool
|
|
});
|
|
|
|
if (!silent) {
|
|
console.log(`Evaluating ${filtered.length} models for ${category} category`);
|
|
}
|
|
|
|
// Phase 1: Estimation filter
|
|
const candidates = [];
|
|
const budget = memoryBudgetGB(hardware);
|
|
|
|
for (const model of filtered) {
|
|
const result = this.evaluateModel(
|
|
model,
|
|
hardware,
|
|
category,
|
|
targetCtx,
|
|
budget,
|
|
optimizationObjective,
|
|
normalizedRuntime,
|
|
{ contextPolicy: Number(options.targetCtx) > 0 ? 'required' : 'preferred' }
|
|
);
|
|
if (result) {
|
|
candidates.push(result);
|
|
}
|
|
}
|
|
|
|
// Sort by score
|
|
candidates.sort((a, b) => b.score - a.score);
|
|
let topCandidates = candidates.slice(0, topN);
|
|
topCandidates = this.ensureFeasibleMidTierCoverage(
|
|
topCandidates,
|
|
candidates,
|
|
category,
|
|
hardware,
|
|
optimizationObjective
|
|
);
|
|
|
|
if (!silent) {
|
|
console.log(`✨ Selected ${topCandidates.length} top candidates`);
|
|
}
|
|
|
|
// Phase 2: Quick probe (optional)
|
|
if (enableProbe && topCandidates.length > 0) {
|
|
if (!silent) {
|
|
console.log(`🔬 Running quick probes...`);
|
|
}
|
|
await this.runQuickProbes(topCandidates, hardware, category);
|
|
// Re-sort after probing
|
|
topCandidates.sort((a, b) => b.score - a.score);
|
|
}
|
|
|
|
return {
|
|
category,
|
|
optimizeFor: optimizationObjective,
|
|
runtime: normalizedRuntime,
|
|
hardware,
|
|
candidates: topCandidates,
|
|
total_evaluated: filtered.length,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
combineModels(installed, catalog) {
|
|
const combined = [...installed];
|
|
const installedNames = new Set(installed.map(m => m.model_identifier));
|
|
|
|
// Add catalog models that aren't installed
|
|
for (const model of catalog) {
|
|
if (!installedNames.has(model.model_identifier)) {
|
|
combined.push(model);
|
|
}
|
|
}
|
|
|
|
return combined;
|
|
}
|
|
|
|
isUncensoredModel(model = {}) {
|
|
return isUncensoredModel(model);
|
|
}
|
|
|
|
filterByCategory(models, category, options = {}) {
|
|
const includeUncensored = options.includeUncensored === true;
|
|
const eligibleModels = filterModelsBySafety(models, {
|
|
includeUncensored,
|
|
referenceModels: options.referenceModels
|
|
});
|
|
return eligibleModels.filter(model => {
|
|
if (this.isCloudVariantTag(model.model_identifier || model.name)) {
|
|
return false;
|
|
}
|
|
|
|
const capability = capabilitiesOf(model);
|
|
if (category === 'embeddings') return capability.embedding && !capability.reranking;
|
|
if (category === 'reranking') return capability.reranking;
|
|
if (!capability.generation) return false;
|
|
const tags = Array.isArray(model.tags) ? model.tags : [];
|
|
const modalities = Array.isArray(model.modalities) ? model.modalities : [];
|
|
const name = String(model.name || model.model_identifier || '').toLowerCase();
|
|
const paramsB = Number(model.paramsB) || 0;
|
|
|
|
switch (category) {
|
|
case 'coding':
|
|
return tags.some(tag => ['coder', 'code', 'instruct'].includes(tag)) ||
|
|
name.includes('code');
|
|
|
|
case 'multimodal':
|
|
return modalities.includes('vision') ||
|
|
tags.includes('vision');
|
|
|
|
case 'embeddings':
|
|
return tags.includes('embedding') ||
|
|
tags.includes('embeddings') ||
|
|
name.includes('embed') ||
|
|
name.includes('bge-') ||
|
|
name.includes('nomic-embed') ||
|
|
name.includes('all-minilm') ||
|
|
model.specialization === 'embeddings';
|
|
|
|
case 'reasoning':
|
|
return tags.includes('instruct') ||
|
|
paramsB >= 7; // Prefer larger models for reasoning
|
|
|
|
default: // general, reading, summarization
|
|
return true; // Most models can handle these
|
|
}
|
|
});
|
|
}
|
|
|
|
evaluateModel(model, hardware, category, targetCtx, budget, optimizeFor = 'balanced', runtime = 'ollama', options = {}) {
|
|
if (!(this.parseBillionsValue(model.totalParamsB || model.paramsB) > 0)) return null;
|
|
const nativeContext = Number(model.ctxMax);
|
|
const requiredContext = options.contextPolicy !== 'preferred';
|
|
if (requiredContext && (!(nativeContext > 0) || nativeContext < targetCtx)) return null;
|
|
const effectiveContext = nativeContext > 0 ? Math.min(nativeContext, targetCtx) : targetCtx;
|
|
if (!(budget > 0) || !Number.isFinite(budget)) return null;
|
|
// 1. Select best fitting quantization
|
|
const bestQuant = this.selectBestQuantization(model, budget, effectiveContext);
|
|
if (!bestQuant) return null;
|
|
|
|
// 2. Calculate required memory
|
|
const memoryEstimate = this.estimateMemoryBreakdown(model, bestQuant.quant, effectiveContext);
|
|
const requiredGB = memoryEstimate.requiredGB;
|
|
if (requiredGB > budget) return null;
|
|
|
|
// 3. Calculate component scores
|
|
const Q = this.calculateQualityPrior(model, bestQuant.quant, category);
|
|
const speedEstimate = this.estimateSpeedProfile(hardware, model, bestQuant.quant, category, runtime);
|
|
const S = speedEstimate.score;
|
|
const F = this.calculateFitScore(requiredGB, budget);
|
|
const C = this.calculateContextScore(model, targetCtx);
|
|
const capacityAdjustment = this.calculateHighCapacitySizeAdjustment(
|
|
hardware,
|
|
model,
|
|
budget,
|
|
category,
|
|
optimizeFor
|
|
);
|
|
|
|
// 4. Calculate final weighted score
|
|
const weights = this.getScoringWeights(category, optimizeFor);
|
|
const weightedScore = Q * weights[0] + S * weights[1] + F * weights[2] + C * weights[3];
|
|
const score = Math.max(
|
|
0,
|
|
Math.min(100, Math.round((weightedScore + capacityAdjustment.score) * 10) / 10)
|
|
);
|
|
|
|
// 5. Build rationale
|
|
const rationale = this.buildRationale(
|
|
hardware,
|
|
model,
|
|
bestQuant.quant,
|
|
requiredGB,
|
|
budget,
|
|
category,
|
|
Q,
|
|
S,
|
|
memoryEstimate,
|
|
speedEstimate,
|
|
capacityAdjustment
|
|
);
|
|
|
|
return {
|
|
meta: model,
|
|
context: { requested: targetCtx, effective: effectiveContext, native: nativeContext > 0 ? nativeContext : null,
|
|
policy: requiredContext ? 'required' : 'preferred', limited: !(nativeContext >= targetCtx) },
|
|
quant: bestQuant.quant,
|
|
requiredGB: Math.round(requiredGB * 10) / 10,
|
|
estTPS: speedEstimate.estimatedTPS,
|
|
score,
|
|
runtime: speedEstimate.runtime,
|
|
rationale,
|
|
memory: {
|
|
requiredGB,
|
|
budgetGB: budget,
|
|
fit: classifyFit(requiredGB, budget),
|
|
modelMemGB: Math.round(memoryEstimate.modelMemGB * 100) / 100,
|
|
kvCacheGB: Math.round(memoryEstimate.kvCacheGB * 100) / 100,
|
|
runtimeOverheadGB: Math.round(memoryEstimate.runtimeOverheadGB * 100) / 100,
|
|
memorySource: memoryEstimate.memorySource,
|
|
assumptionSource: memoryEstimate.parameterProfile.assumptionSource,
|
|
isMoE: memoryEstimate.parameterProfile.isMoE,
|
|
effectiveParamsB: Math.round(memoryEstimate.parameterProfile.effectiveParamsB * 1000) / 1000
|
|
},
|
|
speed: {
|
|
backend: speedEstimate.backend,
|
|
basis: speedEstimate.basis,
|
|
targetTPS: speedEstimate.targetTPS,
|
|
estimatedTPS: speedEstimate.estimatedTPS,
|
|
runtime: speedEstimate.runtime,
|
|
moe: speedEstimate.moe
|
|
},
|
|
components: { Q, S, F, C, H: capacityAdjustment.score },
|
|
optimizeFor
|
|
};
|
|
}
|
|
|
|
getQuantizationCandidates(model) {
|
|
// A model/tag/file is an immutable artifact. Alternative files are separate rows.
|
|
if (model?.quant) return [this.normalizeQuantization(model.quant)];
|
|
const available = model?.availableQuantizations || Object.keys(model?.sizeByQuant || {});
|
|
if (available.length !== 1 && (model?.model_identifier || !available.length)) {
|
|
return Number(model?.sizeGB) > 0 ? ['UNKNOWN'] : [];
|
|
}
|
|
return [...new Set(available.map(normalizePrecision))]
|
|
.sort((a, b) => (precisionProfile(b).bytes || 0) - (precisionProfile(a).bytes || 0));
|
|
}
|
|
|
|
selectBestQuantization(model, budget, targetCtx) {
|
|
for (const quant of this.getQuantizationCandidates(model)) {
|
|
const requiredGB = this.estimateRequiredGB(model, quant, targetCtx);
|
|
if (requiredGB <= budget) return { quant, sizeGB: requiredGB };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
resolveMemoryParameterProfile(model = {}) {
|
|
return resolveMoEParameterProfile(model);
|
|
}
|
|
|
|
estimateMemoryBreakdown(model, quant, ctx) {
|
|
const normalizedQuant = this.normalizeQuantization(quant);
|
|
const bpp = precisionProfile(normalizedQuant).bytes;
|
|
const sizeByQuant = model?.sizeByQuant && typeof model.sizeByQuant === 'object' ? model.sizeByQuant : {};
|
|
const observedFromSizeMap = Number(sizeByQuant[normalizedQuant]);
|
|
const directVariantMatch =
|
|
this.normalizeQuantization(model?.quant || '') === normalizedQuant
|
|
? Number(model?.sizeGB ?? model?.size)
|
|
: NaN;
|
|
|
|
const observedWeightGB = Number.isFinite(observedFromSizeMap) && observedFromSizeMap > 0
|
|
? observedFromSizeMap
|
|
: (Number.isFinite(directVariantMatch) && directVariantMatch > 0 ? directVariantMatch : null);
|
|
|
|
const parameterProfile = this.resolveMemoryParameterProfile(model);
|
|
// Weight memory must account for ALL resident parameters. For MoE under
|
|
// Ollama / Metal / vLLM every expert is resident, so size the weights by
|
|
// the TOTAL parameter count (not the active count). Active params drive
|
|
// speed and KV-cache only. Sizing weights by active params used to make a
|
|
// 236B MoE look like ~14GB and falsely "fit" small hardware.
|
|
const weightParamsB =
|
|
parameterProfile.isMoE && Number.isFinite(parameterProfile.totalParamsB) && parameterProfile.totalParamsB > 0
|
|
? parameterProfile.totalParamsB
|
|
: parameterProfile.effectiveParamsB;
|
|
const modeledWeightGB = bpp == null ? Infinity : weightParamsB * bpp;
|
|
// Full-precision resident weights cannot shrink to a compressed download
|
|
// or an old Q4 size estimate. Quantized artifacts retain their observed size.
|
|
const fullPrecision = ['FP16', 'BF16', 'FP32'].includes(normalizedQuant);
|
|
const useObservedArtifactSize = Number.isFinite(observedWeightGB) && observedWeightGB > 0 &&
|
|
(!fullPrecision || observedWeightGB >= modeledWeightGB);
|
|
const modelMemGB = useObservedArtifactSize ? observedWeightGB : modeledWeightGB;
|
|
const effectiveCtx = Number.isFinite(Number(ctx)) && Number(ctx) > 0 ? Number(ctx) : 4096;
|
|
|
|
// KV cache: ~2 * numLayers * hiddenDim * 2bytes * ctx / 1e9
|
|
// Simplified: ~0.000008 GB per billion params per context token
|
|
const kvCacheGB = 0.000008 * parameterProfile.effectiveParamsB * effectiveCtx;
|
|
|
|
// Runtime overhead (Metal/CUDA context, buffers)
|
|
const runtimeOverhead = useObservedArtifactSize ? 0.35 : 0.5;
|
|
const usedMoeTotal = parameterProfile.isMoE && weightParamsB === parameterProfile.totalParamsB;
|
|
const memorySource = useObservedArtifactSize
|
|
? 'observed_artifact_size'
|
|
: (usedMoeTotal ? 'moe_total_params' : 'estimated_from_params');
|
|
|
|
return {
|
|
parameterProfile,
|
|
memorySource,
|
|
modelMemGB,
|
|
kvCacheGB,
|
|
runtimeOverheadGB: runtimeOverhead,
|
|
requiredGB: modelMemGB + kvCacheGB + runtimeOverhead
|
|
};
|
|
}
|
|
|
|
estimateRequiredGB(model, quant, ctx) {
|
|
return this.estimateMemoryBreakdown(model, quant, ctx).requiredGB;
|
|
}
|
|
|
|
calculateQualityPrior(model, quant, category) {
|
|
// Measured benchmark scores win over the size heuristic whenever we
|
|
// actually have them. `qualityEvals` is injected by the caller; when
|
|
// it is absent or the model was never benchmarked we fall back to the
|
|
// estimate below and mark it as such on the result, so nothing ever
|
|
// presents a guess as a measurement.
|
|
const measured = this.lookupMeasuredQuality(model, category);
|
|
if (measured) {
|
|
let Qm = measured.score;
|
|
Qm += this.quantPenalties[quant] ?? precisionProfile(quant).penalty; // benchmarks are run at fp16
|
|
Qm += this.calculateFreshnessAdjustment(model);
|
|
model.qualitySource = measured.provenance;
|
|
return Math.max(0, Math.min(100, Qm));
|
|
}
|
|
model.qualitySource = { kind: 'estimated', basis: 'parameter count' };
|
|
|
|
// Base quality by parameter count
|
|
let Q = this.getBaseQuality(model.paramsB);
|
|
|
|
// Family bump
|
|
const familyBump = this.familyBumps[model.family] || 0;
|
|
Q += familyBump;
|
|
|
|
// Quantization penalty
|
|
const quantPenalty = this.quantPenalties[quant] ?? precisionProfile(quant).penalty;
|
|
Q += quantPenalty;
|
|
|
|
// Freshness/deprecation adjustment
|
|
const freshnessAdjustment = this.calculateFreshnessAdjustment(model);
|
|
Q += freshnessAdjustment;
|
|
|
|
const pulls = Number(model.pulls || model.actual_pulls || 0);
|
|
if (pulls >= 100000000) Q += 4;
|
|
else if (pulls >= 20000000) Q += 3;
|
|
else if (pulls >= 5000000) Q += 2;
|
|
else if (pulls >= 1000000) Q += 1;
|
|
|
|
// Task alignment bump
|
|
const taskBump = this.getTaskAlignmentBump(model, category);
|
|
Q += taskBump;
|
|
|
|
// Reasoning bonus for larger models
|
|
if (category === 'reasoning' && model.paramsB >= 13) {
|
|
Q += 5;
|
|
}
|
|
|
|
// Coding penalty for non-instruct models
|
|
if (category === 'coding' && !model.tags.some(tag => ['coder', 'instruct'].includes(tag))) {
|
|
Q -= 15;
|
|
}
|
|
|
|
return Math.max(0, Math.min(100, Q));
|
|
}
|
|
|
|
/**
|
|
* Real benchmark score for this model at this size, on the 0-100 axis the
|
|
* selector uses, or null when nothing was measured.
|
|
*
|
|
* Only metrics that actually measure the category are consulted: HumanEval
|
|
* says nothing about vision, so it is never borrowed for it. A category
|
|
* with no matching benchmark returns null rather than a nearby proxy.
|
|
*/
|
|
invalidateQualityEvals() {
|
|
this.qualityConnection?.close();
|
|
this.qualityConnection = null;
|
|
this.qualityEvals = undefined;
|
|
}
|
|
|
|
lookupMeasuredQuality(model, category) {
|
|
// Attach lazily: the selector is constructed in several places that
|
|
// have no database handle, so it opens the shared catalog read-only on
|
|
// first use and degrades to the estimate if that is not possible.
|
|
if (this.qualityEvals === undefined) {
|
|
this.qualityEvals = null;
|
|
let connection;
|
|
try {
|
|
const { DatabaseSync } = require('node:sqlite');
|
|
const { QualityEvals } = require('../data/quality-evals');
|
|
const dbPath = require('path').join(
|
|
require('os').homedir(), '.llm-checker', 'models.db'
|
|
);
|
|
if (require('fs').existsSync(dbPath)) {
|
|
connection = new DatabaseSync(dbPath, { readOnly: true });
|
|
const quality = new QualityEvals(connection, { readOnly: true });
|
|
quality.stats();
|
|
connection.prepare('SELECT 1 FROM catalog_families LIMIT 1').get();
|
|
this.qualityEvals = quality;
|
|
this.qualityConnection = connection;
|
|
}
|
|
} catch { connection?.close(); /* no benchmark data available */ }
|
|
}
|
|
if (!this.qualityEvals) return null;
|
|
|
|
// Preference order per category: the most discriminating benchmark
|
|
// first. LiveBench closes the gap for reasoning, creative and chat —
|
|
// the three categories that previously had no task signal at all and
|
|
// therefore ranked purely by model size.
|
|
const BENCH_FOR_CATEGORY = {
|
|
coding: [
|
|
'bcb_hard_instruct', 'bcb_instruct', 'livebench_coding',
|
|
'livebench_agentic_coding', 'humaneval_plus', 'mbpp_plus', 'bcb_complete',
|
|
],
|
|
reasoning: ['livebench_reasoning', 'hf_bbh', 'hf_gpqa', 'livebench_mathematics', 'hf_math_lvl5', 'hf_musr'],
|
|
creative: ['livebench_language'],
|
|
talking: ['lmarena_chat', 'hf_ifeval', 'livebench_if'],
|
|
general: ['lmarena_general', 'hf_mmlu_pro', 'livebench_data_analysis'],
|
|
multimodal: ['mmmu_val'],
|
|
};
|
|
const wanted = BENCH_FOR_CATEGORY[category];
|
|
if (!wanted) return null; // no benchmark speaks to this category yet
|
|
|
|
let hit;
|
|
try {
|
|
hit = this.qualityEvals.lookup(model.model_identifier || model.name, model.paramsB, category, model);
|
|
} catch { return null; }
|
|
if (!hit?.evals?.length) return null;
|
|
|
|
// Take the most preferred metric that exists for this model.
|
|
const byMetric = new Map(hit.evals.map((e) => [e.metric, e]));
|
|
const chosen = wanted.map((m) => byMetric.get(m)).filter(Boolean)
|
|
.find((entry) => this.qualityEvals.percentile(entry.metric, entry.score) != null);
|
|
if (!chosen) return null;
|
|
|
|
// BigCodeBench runs far below HumanEval on the same model (its top
|
|
// score is ~50 where HumanEval saturates near 90), so the two are
|
|
// rescaled onto a common axis instead of being compared directly.
|
|
// Rank within the metric's own distribution rather than against a
|
|
// fixed ceiling. Boards differ wildly in difficulty — BigCodeBench
|
|
// tops out near 51 while HumanEval+ saturates at 89, and LiveBench's
|
|
// reasoning median sits at 70 because frontier hosted models dominate
|
|
// it. A percentile puts every metric on one comparable axis.
|
|
const pct = this.qualityEvals.percentile(chosen.metric, chosen.score);
|
|
if (pct == null) return null; // too thin a cohort to rank against
|
|
|
|
// Map the percentile onto the SAME band the size estimator produces
|
|
// (getBaseQuality spans 45-95). Handing back a raw 0-100 percentile
|
|
// instead makes measured and estimated models incomparable: measured
|
|
// ones spread across the whole axis while unmeasured ones cluster high,
|
|
// so a benchmarked model scores below an unbenchmarked one and gets
|
|
// punished for having been evaluated. Verified before this change:
|
|
// qwen3@30B measured = 20.8 vs deepseek-r1@14B estimated = 88.0.
|
|
const EST_FLOOR = 45;
|
|
const EST_CEIL = 95;
|
|
const score = EST_FLOOR + (pct / 100) * (EST_CEIL - EST_FLOOR);
|
|
|
|
return {
|
|
score,
|
|
provenance: {
|
|
kind: 'measured',
|
|
metric: chosen.metric,
|
|
rawScore: chosen.score,
|
|
source: chosen.sourceName,
|
|
sourceUrl: chosen.sourceUrl,
|
|
scoreUnit: chosen.scoreUnit,
|
|
independent: chosen.independent,
|
|
benchModel: chosen.benchModel,
|
|
precision: chosen.precision,
|
|
// The board did not publish a size, so this measures the
|
|
// family rather than this exact build. Shown as such.
|
|
sizeUnknown: Boolean(hit.sizeUnknown),
|
|
},
|
|
};
|
|
}
|
|
|
|
getBaseQuality(paramsB) {
|
|
// Find closest parameter count in our table
|
|
const keys = Object.keys(this.baseQualityByParams).map(Number).sort((a, b) => a - b);
|
|
|
|
for (let i = 0; i < keys.length; i++) {
|
|
if (paramsB <= keys[i]) {
|
|
return this.baseQualityByParams[keys[i]];
|
|
}
|
|
}
|
|
|
|
// If larger than our table, return the largest
|
|
return this.baseQualityByParams[keys[keys.length - 1]];
|
|
}
|
|
|
|
getTaskAlignmentBump(model, category) {
|
|
const name = model.name.toLowerCase();
|
|
const tags = model.tags;
|
|
|
|
switch (category) {
|
|
case 'coding':
|
|
if (tags.includes('coder') || name.includes('code')) return 6;
|
|
if (tags.includes('instruct')) return 2;
|
|
return 0;
|
|
|
|
case 'multimodal':
|
|
if (model.modalities?.includes('vision')) return 6;
|
|
return 0;
|
|
|
|
case 'general':
|
|
if (tags.includes('chat') || tags.includes('instruct')) return 4;
|
|
if (name.includes('code')) return 2;
|
|
return 0;
|
|
|
|
default:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
calculateFreshnessAdjustment(model = {}) {
|
|
const freshnessScore = Number.isFinite(model.freshnessScore) ? model.freshnessScore : 55;
|
|
const ageDays = Number.isFinite(model.modelAgeDays) ? model.modelAgeDays : null;
|
|
const isDeprecated = Boolean(model.isDeprecated);
|
|
const isStale = Boolean(model.isStale);
|
|
|
|
if (isDeprecated) return -12;
|
|
if (ageDays !== null && ageDays > this.freshnessThresholds.veryStaleDays) return -8;
|
|
if (ageDays !== null && ageDays > this.freshnessThresholds.staleDays) return -4;
|
|
if (isStale) return -3;
|
|
if (freshnessScore >= 90) return 3;
|
|
if (freshnessScore >= 75) return 2;
|
|
if (freshnessScore >= 60) return 1;
|
|
if (freshnessScore <= 25) return -4;
|
|
return 0;
|
|
}
|
|
|
|
estimateSpeed(hardware, model, quant, category, runtime = 'ollama') {
|
|
return this.estimateSpeedProfile(hardware, model, quant, category, runtime).score;
|
|
}
|
|
|
|
estimateSpeedProfile(hardware, model, quant, category, runtime = 'ollama') {
|
|
const backend = detectedBackend(hardware);
|
|
|
|
// Base speed calculation
|
|
const K = this.backendK[backend];
|
|
const denseParamsB = Number.isFinite(this.parseBillionsValue(model.paramsB))
|
|
? this.parseBillionsValue(model.paramsB)
|
|
: 1;
|
|
const parameterProfile = this.resolveMemoryParameterProfile(model);
|
|
const effectiveParamsB = Number.isFinite(parameterProfile.effectiveParamsB) && parameterProfile.effectiveParamsB > 0
|
|
? parameterProfile.effectiveParamsB
|
|
: denseParamsB;
|
|
let base = K / effectiveParamsB;
|
|
|
|
// Quantization multiplier
|
|
const quantMultiplier = this.quantSpeedMultipliers[quant] ?? precisionProfile(quant).speed;
|
|
base *= quantMultiplier;
|
|
|
|
// Threading multiplier
|
|
if (hardware.cpu.cores >= 8) base *= 1.1;
|
|
if (!backend.startsWith('cpu_')) base *= 1.2;
|
|
|
|
const acceleratorScale = this.calculateAcceleratorSpeedScale(hardware, backend);
|
|
base *= acceleratorScale.multiplier;
|
|
|
|
const normalizedRuntime = normalizeMoERuntime(runtime);
|
|
const moe = estimateMoESpeedMultiplier({
|
|
model,
|
|
runtime: normalizedRuntime,
|
|
denseParamsB,
|
|
parameterProfile
|
|
});
|
|
if (moe.applied) {
|
|
base *= moe.multiplier;
|
|
}
|
|
|
|
// Normalize to 0-100 score
|
|
const target = this.targetSpeeds[category] || this.targetSpeeds.general;
|
|
const estimatedTPS = Math.max(1, Math.round(base * 10) / 10);
|
|
const score = Math.min(100, Math.round((100 * estimatedTPS / target) * 10) / 10);
|
|
|
|
return {
|
|
backend,
|
|
basis: 'backend_profile_estimate',
|
|
targetTPS: target,
|
|
estimatedTPS,
|
|
score,
|
|
runtime: normalizedRuntime,
|
|
moe,
|
|
acceleratorScale
|
|
};
|
|
}
|
|
|
|
calculateAcceleratorSpeedScale(hardware = {}, backend = 'cpu_x86') {
|
|
if (!['cuda', 'metal', 'rocm', 'vulkan', 'sycl'].includes(backend)) {
|
|
return { multiplier: 1, reason: null };
|
|
}
|
|
|
|
const gpu = hardware.gpu || {};
|
|
const memory = hardware.memory || {};
|
|
const toFiniteNumber = (value, fallback = 0) => {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
};
|
|
const vramGB = toFiniteNumber(gpu.vramGB ?? gpu.vram ?? gpu.totalVRAM, 0);
|
|
const ramGB = toFiniteNumber(memory.totalGB ?? memory.total, 0);
|
|
const acceleratorMemoryGB = backend === 'metal' && Boolean(gpu.unified)
|
|
? Math.max(vramGB, ramGB)
|
|
: vramGB;
|
|
const gpuCount = Math.max(1, toFiniteNumber(gpu.gpuCount ?? gpu.count, 1));
|
|
|
|
let multiplier = 1;
|
|
if (acceleratorMemoryGB >= 160) multiplier *= 3.2;
|
|
else if (acceleratorMemoryGB >= 96) multiplier *= 2.6;
|
|
else if (acceleratorMemoryGB >= 80) multiplier *= 2.2;
|
|
else if (acceleratorMemoryGB >= 48) multiplier *= 1.7;
|
|
else if (acceleratorMemoryGB >= 24) multiplier *= 1.15;
|
|
|
|
if (['cuda', 'rocm'].includes(backend) && gpuCount > 1) {
|
|
multiplier *= Math.min(1.8, 1 + ((gpuCount - 1) * 0.25));
|
|
}
|
|
|
|
const rounded = Math.round(multiplier * 100) / 100;
|
|
return {
|
|
multiplier: rounded,
|
|
reason: rounded > 1
|
|
? `${backend.toUpperCase()} capacity x${rounded}`
|
|
: null
|
|
};
|
|
}
|
|
|
|
calculateFitScore(requiredGB, budgetGB) {
|
|
const ratio = requiredGB / budgetGB;
|
|
if (ratio <= 0.9) return 100;
|
|
if (ratio <= 1.0) return 70;
|
|
return 0; // Unreachable in practice: evaluateModel drops requiredGB > budget.
|
|
}
|
|
|
|
calculateContextScore(model, targetCtx) {
|
|
const ctxMax = Number(model?.ctxMax) || 0;
|
|
if (ctxMax >= targetCtx) return 100;
|
|
if (ctxMax >= targetCtx * 0.5) return 70;
|
|
// Required contexts are filtered before scoring. Preferred contexts
|
|
// contribute a lower score when the model window is shorter.
|
|
return 0;
|
|
}
|
|
|
|
getHighCapacitySizeTarget(budgetGB, hardware = {}) {
|
|
if (!Number.isFinite(budgetGB) || budgetGB < 32) return null;
|
|
|
|
const isMultiGPU = Boolean(hardware?.gpu?.isMultiGPU);
|
|
if (budgetGB >= 128) return { minParamsB: 30, sweetSpotParamsB: 70 };
|
|
if (budgetGB >= 80) return { minParamsB: 30, sweetSpotParamsB: 70 };
|
|
if (budgetGB >= 48) return { minParamsB: 20, sweetSpotParamsB: 34 };
|
|
if (budgetGB >= 32 && isMultiGPU) return { minParamsB: 30, sweetSpotParamsB: 30 };
|
|
if (budgetGB >= 32) return { minParamsB: 13, sweetSpotParamsB: 30 };
|
|
return null;
|
|
}
|
|
|
|
calculateHighCapacitySizeAdjustment(hardware, model, budgetGB, category, optimizeFor = 'balanced') {
|
|
const objective = this.normalizeOptimizationObjective(optimizeFor);
|
|
if (objective === 'speed' || category === 'embeddings') {
|
|
return { score: 0, reason: null };
|
|
}
|
|
|
|
const normalizedHardware = this.normalizeHardwareProfile(hardware || {});
|
|
const tier = this.mapHardwareTier(normalizedHardware);
|
|
const highCapacityTiers = new Set(['very_high', 'ultra_high', 'extreme', 'flagship']);
|
|
const target = this.getHighCapacitySizeTarget(budgetGB, normalizedHardware);
|
|
const hasHighCapacitySignal =
|
|
Boolean(target) ||
|
|
highCapacityTiers.has(tier) ||
|
|
Number(normalizedHardware?.gpu?.vramGB || 0) >= 48;
|
|
|
|
if (!hasHighCapacitySignal || !target) {
|
|
return { score: 0, reason: null };
|
|
}
|
|
|
|
const params = this.parseBillionsValue(model?.paramsB);
|
|
if (!Number.isFinite(params) || params <= 0) {
|
|
return { score: 0, reason: null };
|
|
}
|
|
|
|
const categoryMultiplier = category === 'multimodal' ? 0.6 : 1;
|
|
if (params < target.minParamsB) {
|
|
const deficitRatio = (target.minParamsB - params) / target.minParamsB;
|
|
const penalty = -Math.min(24, deficitRatio * 24) * categoryMultiplier;
|
|
const roundedPenalty = Math.round(penalty * 10) / 10;
|
|
return {
|
|
score: roundedPenalty,
|
|
reason: `below ${target.minParamsB}B high-capacity floor`
|
|
};
|
|
}
|
|
|
|
const distanceRatio = Math.min(
|
|
1,
|
|
Math.abs(params - target.sweetSpotParamsB) / target.sweetSpotParamsB
|
|
);
|
|
const bonus = Math.max(0, 12 * (1 - distanceRatio)) * categoryMultiplier;
|
|
const roundedBonus = Math.round(bonus * 10) / 10;
|
|
|
|
return {
|
|
score: roundedBonus,
|
|
reason: roundedBonus > 0
|
|
? `${target.sweetSpotParamsB}B high-capacity target`
|
|
: null
|
|
};
|
|
}
|
|
|
|
estimatePracticalMaxParamsForBudget(budgetGB) {
|
|
if (!Number.isFinite(budgetGB) || budgetGB <= 0) return 4;
|
|
if (budgetGB >= 80) return 70;
|
|
if (budgetGB >= 48) return 46;
|
|
if (budgetGB >= 32) return 30;
|
|
if (budgetGB >= 24) return 14;
|
|
if (budgetGB >= 16) return 8;
|
|
return 4;
|
|
}
|
|
|
|
ensureFeasibleMidTierCoverage(selectedCandidates, allCandidates, category, hardware, optimizeFor = 'balanced') {
|
|
if (!Array.isArray(selectedCandidates) || selectedCandidates.length === 0) {
|
|
return selectedCandidates;
|
|
}
|
|
|
|
const objective = this.normalizeOptimizationObjective(optimizeFor);
|
|
if (objective === 'speed') {
|
|
return selectedCandidates;
|
|
}
|
|
|
|
const enforceCategories = new Set(['general', 'talking', 'reading', 'coding', 'reasoning', 'multimodal']);
|
|
if (!enforceCategories.has(category)) {
|
|
return selectedCandidates;
|
|
}
|
|
|
|
const normalizedHardware = this.normalizeHardwareProfile(hardware || {});
|
|
const budget = normalizedHardware.gpu.unified
|
|
? normalizedHardware.usableMemGB
|
|
: (normalizedHardware.gpu.vramGB || normalizedHardware.usableMemGB);
|
|
|
|
if (!Number.isFinite(budget) || budget < 16) {
|
|
return selectedCandidates;
|
|
}
|
|
|
|
const candidatePool = Array.isArray(allCandidates) && allCandidates.length > 0
|
|
? allCandidates
|
|
: selectedCandidates;
|
|
let promoted = [...selectedCandidates];
|
|
|
|
const minMidTierParams = budget >= 24 ? 7 : 6;
|
|
const alreadyHasMidTier = promoted.some((candidate) => (candidate?.meta?.paramsB || 0) >= minMidTierParams);
|
|
if (!alreadyHasMidTier) {
|
|
const practicalSpeedFloor = normalizedHardware.gpu.unified ? 25 : 20;
|
|
const feasibleMidTier = candidatePool.find((candidate) => {
|
|
const params = candidate?.meta?.paramsB || 0;
|
|
const speedScore = candidate?.components?.S ?? candidate?.estTPS ?? 0;
|
|
return params >= minMidTierParams && speedScore >= practicalSpeedFloor;
|
|
});
|
|
|
|
if (
|
|
feasibleMidTier &&
|
|
!promoted.some((candidate) => candidate?.meta?.model_identifier === feasibleMidTier?.meta?.model_identifier)
|
|
) {
|
|
promoted[promoted.length - 1] = feasibleMidTier;
|
|
promoted.sort((a, b) => b.score - a.score);
|
|
}
|
|
}
|
|
|
|
const practicalMaxParams = this.estimatePracticalMaxParamsForBudget(budget);
|
|
const shouldEnforceThirtyBCoverage =
|
|
Boolean(normalizedHardware?.gpu?.isMultiGPU) &&
|
|
!Boolean(normalizedHardware?.gpu?.unified) &&
|
|
practicalMaxParams >= 30;
|
|
|
|
if (!shouldEnforceThirtyBCoverage || objective === 'speed') {
|
|
return promoted;
|
|
}
|
|
|
|
const alreadyHasThirtyB = promoted.some((candidate) => (candidate?.meta?.paramsB || 0) >= 30);
|
|
if (alreadyHasThirtyB) {
|
|
return promoted;
|
|
}
|
|
|
|
const largeModelSpeedFloor = Math.max(
|
|
8,
|
|
Math.round((this.targetSpeeds[category] || this.targetSpeeds.general) * 0.2)
|
|
);
|
|
const feasibleThirtyB = candidatePool.find((candidate) => {
|
|
const params = candidate?.meta?.paramsB || 0;
|
|
const estTPS = candidate?.estTPS ?? candidate?.speed?.estimatedTPS ?? 0;
|
|
return params >= 30 && estTPS >= largeModelSpeedFloor;
|
|
});
|
|
|
|
if (!feasibleThirtyB) {
|
|
return promoted;
|
|
}
|
|
|
|
if (promoted.some((candidate) => candidate?.meta?.model_identifier === feasibleThirtyB?.meta?.model_identifier)) {
|
|
return promoted;
|
|
}
|
|
|
|
const highCapacityPromoted = [...promoted];
|
|
highCapacityPromoted[highCapacityPromoted.length - 1] = feasibleThirtyB;
|
|
highCapacityPromoted.sort((a, b) => b.score - a.score);
|
|
return highCapacityPromoted;
|
|
}
|
|
|
|
buildRationale(
|
|
hardware,
|
|
model,
|
|
quant,
|
|
requiredGB,
|
|
budget,
|
|
category,
|
|
Q,
|
|
S,
|
|
memoryEstimate = null,
|
|
speedEstimate = null,
|
|
capacityAdjustment = null
|
|
) {
|
|
const parts = [];
|
|
|
|
// Memory fit
|
|
parts.push(`fits in ${requiredGB}/${budget}GB`);
|
|
|
|
// Quantization
|
|
parts.push(quant);
|
|
|
|
// Special attributes
|
|
if (model.tags.includes('coder')) parts.push('coder-tuned');
|
|
if (model.modalities?.includes('vision')) parts.push('vision-capable');
|
|
if (model.isDeprecated) parts.push('deprecated penalized');
|
|
else if (model.isStale) parts.push('stale penalized');
|
|
else if (model.freshnessScore >= 90) parts.push('fresh release');
|
|
|
|
const memoryProfile = memoryEstimate?.parameterProfile;
|
|
if (memoryProfile?.isMoE) {
|
|
const assumptionLabels = {
|
|
moe_active_metadata: 'MoE active params',
|
|
moe_derived_expert_ratio: 'MoE derived active ratio',
|
|
moe_fallback_total_params: 'MoE fallback total params',
|
|
moe_fallback_model_params: 'MoE fallback model params',
|
|
moe_fallback_default: 'MoE fallback default'
|
|
};
|
|
parts.push(assumptionLabels[memoryProfile.assumptionSource] || memoryProfile.assumptionSource);
|
|
}
|
|
|
|
if (speedEstimate?.moe?.applied) {
|
|
const runtimeLabel = speedEstimate.runtime || 'ollama';
|
|
const multiplier = Number(speedEstimate.moe.multiplier || 1).toFixed(2);
|
|
parts.push(`MoE speed x${multiplier} (${runtimeLabel})`);
|
|
}
|
|
|
|
if (speedEstimate?.acceleratorScale?.multiplier > 1) {
|
|
parts.push(speedEstimate.acceleratorScale.reason);
|
|
}
|
|
|
|
if (capacityAdjustment?.reason) {
|
|
parts.push(capacityAdjustment.reason);
|
|
}
|
|
|
|
// Size sweet spot
|
|
if (model.paramsB >= 7 && model.paramsB <= 13) {
|
|
parts.push(`${model.paramsB}B is sweet spot`);
|
|
}
|
|
|
|
// Backend
|
|
const backend = speedEstimate?.backend || detectedBackend(hardware);
|
|
parts.push(`${backend.replace('_', ' ').toUpperCase()} speed estimate`);
|
|
|
|
return parts.join(', ');
|
|
}
|
|
|
|
// ============================================================================
|
|
// PHASE 2: QUICK PROBE (Optional)
|
|
// ============================================================================
|
|
|
|
async runQuickProbes(candidates, hardware, category) {
|
|
this.activeCpuOnly = Boolean(hardware?.cpuOnly);
|
|
if (typeof this.ollamaClient.setCpuOnly === 'function') {
|
|
this.ollamaClient.setCpuOnly(this.activeCpuOnly);
|
|
}
|
|
// Load cached results
|
|
const cache = this.loadBenchCache();
|
|
const hardwareFingerprint = this.getHardwareFingerprint(hardware);
|
|
|
|
for (const candidate of candidates) {
|
|
const cacheKey = `${hardwareFingerprint}_${candidate.meta.model_identifier}@${candidate.quant}`;
|
|
|
|
// Check cache first
|
|
if (cache[cacheKey] && this.isCacheValid(cache[cacheKey])) {
|
|
const cachedTPS = cache[cacheKey].tps;
|
|
this.updateCandidateWithMeasuredSpeed(candidate, cachedTPS, category);
|
|
candidate.rationale += ` | measured ${cachedTPS.toFixed(1)} t/s (cached)`;
|
|
continue;
|
|
}
|
|
|
|
// Run probe
|
|
try {
|
|
const measuredTPS = await this.runSingleProbe(candidate.meta.model_identifier, category);
|
|
this.updateCandidateWithMeasuredSpeed(candidate, measuredTPS, category);
|
|
candidate.rationale += ` | measured ${measuredTPS.toFixed(1)} t/s`;
|
|
|
|
// Cache result
|
|
cache[cacheKey] = {
|
|
tps: measuredTPS,
|
|
timestamp: Date.now(),
|
|
category
|
|
};
|
|
this.saveBenchCache(cache);
|
|
|
|
} catch (error) {
|
|
console.warn(`Probe failed for ${candidate.meta.name}: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async runSingleProbe(modelId, category) {
|
|
const prompts = {
|
|
'coding': 'Write 3 bullet points about the benefits of unit tests.',
|
|
'general': 'Explain the benefits of regular exercise in 3 sentences.',
|
|
'reasoning': 'What are the steps to solve a quadratic equation?',
|
|
'multimodal': 'Describe what you see in this image.', // Text-only fallback
|
|
'summarization': 'Summarize the key points of effective communication.',
|
|
'reading': 'What are the main themes in classic literature?'
|
|
};
|
|
|
|
const prompt = prompts[category] || prompts['general'];
|
|
const targetTokens = 128;
|
|
|
|
const result = await this.ollamaClient.generate(modelId, prompt, {
|
|
generationOptions: {
|
|
num_predict: targetTokens,
|
|
...(this.activeCpuOnly ? { num_gpu: 0 } : {})
|
|
}
|
|
});
|
|
|
|
if (Number.isFinite(result.tokensPerSecond) && result.tokensPerSecond > 0) {
|
|
return result.tokensPerSecond;
|
|
}
|
|
|
|
const elapsedSeconds = Math.max(0.001, Number(result.responseTime || 0) / 1000);
|
|
const estimatedResponseTokens = result.response
|
|
? result.response.split(/\s+/).filter(Boolean).length * 1.3
|
|
: targetTokens;
|
|
const tokensGenerated = Number(result.eval_count) || estimatedResponseTokens;
|
|
|
|
return tokensGenerated / elapsedSeconds;
|
|
}
|
|
|
|
updateCandidateWithMeasuredSpeed(candidate, measuredTPS, category) {
|
|
const normalizedS = this.normalizeTPSToScore(measuredTPS, category);
|
|
|
|
// Re-score with the measured speed using the SAME weighting source as
|
|
// evaluateModel: getScoringWeights honours the user's optimizeFor profile and
|
|
// falls back to the general weights for categories (e.g. 'talking') that have
|
|
// no entry in DETERMINISTIC_WEIGHTS — indexing this.categoryWeights[category]
|
|
// directly threw a TypeError for those. We also re-add the stored capacity
|
|
// adjustment (H) and clamp, so a probed score stays comparable to a
|
|
// non-probed one instead of being silently lower.
|
|
const weights = this.getScoringWeights(category, candidate.optimizeFor || 'balanced');
|
|
const { Q, F, C, H = 0 } = candidate.components;
|
|
|
|
candidate.estTPS = measuredTPS;
|
|
candidate.components.S = normalizedS;
|
|
const weighted = Q * weights[0] + normalizedS * weights[1] + F * weights[2] + C * weights[3];
|
|
candidate.score = Math.max(0, Math.min(100, Math.round((weighted + H) * 10) / 10));
|
|
}
|
|
|
|
normalizeTPSToScore(tps, category) {
|
|
const target = this.targetSpeeds[category] || this.targetSpeeds.general;
|
|
return Math.min(100, Math.round((100 * tps / target) * 10) / 10);
|
|
}
|
|
|
|
loadBenchCache() {
|
|
try {
|
|
if (fs.existsSync(this.benchCachePath)) {
|
|
return JSON.parse(fs.readFileSync(this.benchCachePath, 'utf8'));
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to load benchmark cache:', error.message);
|
|
}
|
|
return {};
|
|
}
|
|
|
|
saveBenchCache(cache) {
|
|
try {
|
|
const dir = path.dirname(this.benchCachePath);
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
fs.writeFileSync(this.benchCachePath, JSON.stringify(cache, null, 2));
|
|
} catch (error) {
|
|
console.warn('Failed to save benchmark cache:', error.message);
|
|
}
|
|
}
|
|
|
|
isCacheValid(cacheEntry) {
|
|
const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
return (Date.now() - cacheEntry.timestamp) < maxAge;
|
|
}
|
|
|
|
getHardwareFingerprint(hardware) {
|
|
const executionMode = hardware.cpuOnly
|
|
? 'cpu-only'
|
|
: (hardware.summary?.bestBackend || hardware.gpu.type || 'cpu');
|
|
return `${hardware.cpu.architecture}_${hardware.cpu.cores}c_${hardware.memory.totalGB}gb_${executionMode}`;
|
|
}
|
|
|
|
// ============================================================================
|
|
// FORMAT HELPERS (migrated from enhanced-selector.js)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Map a candidate to the legacy format expected by callers
|
|
*/
|
|
mapCandidateToLegacyFormat(candidate) {
|
|
const provenance = candidate.meta.provenance || {
|
|
source: candidate.meta.source || 'unknown',
|
|
registry: candidate.meta.registry || 'unknown',
|
|
version: candidate.meta.version || 'unknown',
|
|
license: candidate.meta.license || 'unknown',
|
|
digest: candidate.meta.digest || 'unknown'
|
|
};
|
|
|
|
return {
|
|
model_name: candidate.meta.name,
|
|
model_identifier: candidate.meta.model_identifier,
|
|
categoryScore: candidate.score,
|
|
hardwareScore: candidate.components ? candidate.components.F : 90,
|
|
specializationScore: candidate.components ? candidate.components.Q : 85,
|
|
popularityScore: candidate.components ? Math.min(100, (candidate.meta.pulls || 0) / 100000 * 100) : 10,
|
|
efficiencyScore: candidate.components ? candidate.components.S : 80,
|
|
pulls: candidate.meta.pulls || 0,
|
|
size: candidate.meta.paramsB,
|
|
family: candidate.meta.family,
|
|
category: this.inferCategoryFromModel(candidate.meta),
|
|
tags: candidate.meta.tags || [],
|
|
quantization: candidate.quant,
|
|
estimatedRAM: candidate.requiredGB,
|
|
memory: candidate.memory,
|
|
context: candidate.context,
|
|
speed: candidate.speed,
|
|
artifact: candidate.meta.artifact,
|
|
reasoning: candidate.rationale,
|
|
runtime: candidate.runtime || candidate.speed?.runtime || 'ollama',
|
|
installCommand: candidate.meta.installCommand || provenance.install_command || '',
|
|
downloadUrl: candidate.meta.downloadUrl || provenance.download_url || '',
|
|
artifactFormat: candidate.meta.artifact?.format || '',
|
|
memoryAssumptionSource: candidate.memory?.assumptionSource || 'dense_params',
|
|
// Where the quality half of the score came from: a real benchmark
|
|
// or the parameter-count estimate. Carried all the way out so the
|
|
// UI can label it — a 23%-measured catalog must not present an
|
|
// estimate and a measurement as the same kind of number.
|
|
qualitySource: candidate.meta.qualitySource || { kind: 'estimated', basis: 'parameter count' },
|
|
speedAssumptions: candidate.speed?.moe ? {
|
|
applied: Boolean(candidate.speed.moe.applied),
|
|
runtime: candidate.speed.runtime || candidate.runtime || 'ollama',
|
|
multiplier: Number.isFinite(candidate.speed.moe.multiplier) ? candidate.speed.moe.multiplier : 1,
|
|
theoreticalSpeedup: Number.isFinite(candidate.speed.moe.theoreticalSpeedup) ? candidate.speed.moe.theoreticalSpeedup : 1,
|
|
overheadMultiplier: Number.isFinite(candidate.speed.moe.overheadMultiplier) ? candidate.speed.moe.overheadMultiplier : 1,
|
|
assumptionSource: candidate.speed.moe.assumptionSource || candidate.memory?.assumptionSource || 'dense_params'
|
|
} : null,
|
|
source: provenance.source,
|
|
registry: provenance.registry,
|
|
version: provenance.version,
|
|
license: provenance.license,
|
|
digest: provenance.digest,
|
|
provenance
|
|
};
|
|
}
|
|
|
|
mapHardwareTier(hardware = {}) {
|
|
const summary = hardware?.summary || {};
|
|
const canonicalTier = summary.hardwareTier || summary.hardware_tier;
|
|
if (typeof canonicalTier === 'string' && canonicalTier.trim()) {
|
|
return canonicalTier.trim().toLowerCase().replace(/\s+/g, '_');
|
|
}
|
|
const effectiveMemory = Number(summary.effectiveMemory);
|
|
const speedCoefficient = Number(summary.speedCoefficient);
|
|
if (Number.isFinite(effectiveMemory) && effectiveMemory > 0 && Number.isFinite(speedCoefficient)) {
|
|
if (effectiveMemory >= 80 && speedCoefficient >= 300) return 'ultra_high';
|
|
if (effectiveMemory >= 48 && speedCoefficient >= 200) return 'very_high';
|
|
if (effectiveMemory >= 24 && speedCoefficient >= 150) return 'high';
|
|
if (effectiveMemory >= 16 && speedCoefficient >= 100) return 'medium_high';
|
|
if (effectiveMemory >= 12 && speedCoefficient >= 80) return 'medium';
|
|
if (effectiveMemory >= 8 && speedCoefficient >= 50) return 'medium_low';
|
|
if (effectiveMemory >= 6 && speedCoefficient >= 30) return 'low';
|
|
return 'ultra_low';
|
|
}
|
|
|
|
let ram, cores;
|
|
|
|
if (hardware?.memory?.totalGB) {
|
|
ram = hardware.memory.totalGB;
|
|
} else if (hardware?.memory?.total) {
|
|
ram = hardware.memory.total;
|
|
} else if (hardware?.total_ram_gb) {
|
|
ram = hardware.total_ram_gb;
|
|
} else {
|
|
ram = 8;
|
|
}
|
|
|
|
if (hardware?.cpu?.cores) {
|
|
cores = hardware.cpu.cores;
|
|
} else if (hardware?.cpu_cores) {
|
|
cores = hardware.cpu_cores;
|
|
} else {
|
|
cores = 4;
|
|
}
|
|
|
|
const gpu = hardware?.gpu || {};
|
|
const gpuCount =
|
|
(Number.isFinite(Number(gpu.gpuCount)) ? Number(gpu.gpuCount) : null) ??
|
|
(Number.isFinite(Number(hardware?.gpuCount)) ? Number(hardware.gpuCount) : null) ??
|
|
1;
|
|
const totalVRAM =
|
|
(Number.isFinite(Number(gpu.vramGB)) ? Number(gpu.vramGB) : null) ??
|
|
(Number.isFinite(Number(gpu.vram)) ? Number(gpu.vram) : null) ??
|
|
(Number.isFinite(Number(gpu.totalVRAM)) ? Number(gpu.totalVRAM) : null) ??
|
|
0;
|
|
const unifiedGPU = Boolean(gpu.unified) || gpu.type === 'apple_silicon';
|
|
const effectiveAcceleratorMem = unifiedGPU ? Math.max(totalVRAM, ram) : totalVRAM;
|
|
|
|
if (effectiveAcceleratorMem >= 80 || (ram >= 64 && cores >= 16)) return 'extreme';
|
|
if (effectiveAcceleratorMem >= 48 || (ram >= 32 && cores >= 12)) return 'very_high';
|
|
if (effectiveAcceleratorMem >= 24 || (ram >= 16 && cores >= 8)) return 'high';
|
|
if (gpuCount >= 2 && effectiveAcceleratorMem >= 20) return 'high';
|
|
if (ram >= 8 && cores >= 4) return 'medium';
|
|
return 'low';
|
|
}
|
|
|
|
getCategoryInfo(category) {
|
|
const categoryData = {
|
|
coding: { weight: 1.0, keywords: ['code', 'programming', 'coder'] },
|
|
reasoning: { weight: 1.2, keywords: ['reasoning', 'logic', 'math'] },
|
|
multimodal: { weight: 1.1, keywords: ['vision', 'image', 'multimodal'] },
|
|
creative: { weight: 0.9, keywords: ['creative', 'writing', 'story'] },
|
|
talking: { weight: 1.0, keywords: ['chat', 'conversation', 'assistant'] },
|
|
reading: { weight: 1.0, keywords: ['reading', 'comprehension', 'text'] },
|
|
general: { weight: 1.0, keywords: ['general', 'assistant', 'helper'] }
|
|
};
|
|
return categoryData[category] || categoryData.general;
|
|
}
|
|
|
|
inferCategoryFromModel(model) {
|
|
const name = model.name.toLowerCase();
|
|
const tags = model.tags || [];
|
|
|
|
if (tags.includes('coder') || name.includes('code')) return 'coding';
|
|
if (tags.includes('vision') || (model.modalities && model.modalities?.includes('vision'))) return 'multimodal';
|
|
if (tags.includes('embed')) return 'embeddings';
|
|
if (name.includes('creative') || name.includes('wizard')) return 'creative';
|
|
|
|
return 'general';
|
|
}
|
|
|
|
formatModelSize(model) {
|
|
if (model.paramsB) return `${model.paramsB}B`;
|
|
if (model.size) return `${model.size}B`;
|
|
return 'Unknown';
|
|
}
|
|
|
|
/**
|
|
* Generate recommendations by category (main API, replaces EnhancedModelSelector)
|
|
*/
|
|
async getBestModelsForHardware(hardware, allModels, options = {}) {
|
|
const categories = ['coding', 'reasoning', 'multimodal', 'creative', 'talking', 'reading', 'general'];
|
|
const recommendations = {};
|
|
const normalizedPool = this.normalizeExternalModels(Array.isArray(allModels) ? allModels : []);
|
|
const installedModels = await this.getInstalledModels();
|
|
const normalizedHardware = this.normalizeHardwareProfile(hardware || await this.getHardware(), {
|
|
...(Object.prototype.hasOwnProperty.call(options, 'cpuOnly')
|
|
? { cpuOnly: options.cpuOnly }
|
|
: {})
|
|
});
|
|
const runtime = normalizeMoERuntime(options.runtime || 'ollama');
|
|
const optimizationObjective = this.normalizeOptimizationObjective(
|
|
options.optimizeFor || options.optimize || options.objective
|
|
);
|
|
|
|
for (const category of categories) {
|
|
try {
|
|
const result = await this.selectModels(category, {
|
|
topN: 3,
|
|
enableProbe: false,
|
|
silent: true,
|
|
optimizeFor: optimizationObjective,
|
|
runtime,
|
|
...(Object.prototype.hasOwnProperty.call(options, 'cpuOnly')
|
|
? { cpuOnly: options.cpuOnly }
|
|
: {}),
|
|
hardware: normalizedHardware,
|
|
installedModels,
|
|
modelPool: normalizedPool,
|
|
includeUncensored: options.includeUncensored === true
|
|
});
|
|
|
|
recommendations[category] = {
|
|
tier: this.mapHardwareTier(normalizedHardware),
|
|
optimizeFor: optimizationObjective,
|
|
runtime,
|
|
bestModels: result.candidates.map(candidate => this.mapCandidateToLegacyFormat(candidate)),
|
|
totalEvaluated: result.total_evaluated,
|
|
category: this.getCategoryInfo(category)
|
|
};
|
|
} catch (error) {
|
|
recommendations[category] = {
|
|
tier: this.mapHardwareTier(normalizedHardware),
|
|
optimizeFor: optimizationObjective,
|
|
runtime,
|
|
bestModels: [],
|
|
totalEvaluated: 0,
|
|
category: this.getCategoryInfo(category)
|
|
};
|
|
}
|
|
}
|
|
|
|
return recommendations;
|
|
}
|
|
|
|
/**
|
|
* Generate recommendation summary
|
|
*/
|
|
generateRecommendationSummary(recommendations, hardware, options = {}) {
|
|
const summary = {
|
|
hardware_tier: this.mapHardwareTier(hardware),
|
|
optimize_for: this.normalizeOptimizationObjective(
|
|
options.optimizeFor || options.optimize || options.objective
|
|
),
|
|
total_categories: Object.keys(recommendations).length,
|
|
best_overall: null,
|
|
by_category: {},
|
|
quick_commands: []
|
|
};
|
|
|
|
let bestOverallScore = 0;
|
|
let bestOverallModel = null;
|
|
let bestOverallCategory = null;
|
|
|
|
Object.entries(recommendations).forEach(([category, data]) => {
|
|
const bestModel = data.bestModels[0];
|
|
if (bestModel) {
|
|
const command = bestModel.installCommand ||
|
|
bestModel.provenance?.install_command ||
|
|
`ollama pull ${bestModel.model_identifier}`;
|
|
summary.by_category[category] = {
|
|
name: bestModel.model_name || bestModel.name,
|
|
identifier: bestModel.model_identifier,
|
|
score: Math.round(bestModel.categoryScore || bestModel.score),
|
|
command,
|
|
size: this.formatModelSize(bestModel),
|
|
quantization: bestModel.quantization || bestModel.quant || 'Q4_K_M',
|
|
runtime: bestModel.runtime || bestModel.provenance?.runtime || 'ollama',
|
|
pulls: bestModel.pulls || 0,
|
|
source: bestModel.source || bestModel.provenance?.source || 'unknown',
|
|
registry: bestModel.registry || bestModel.provenance?.registry || 'unknown',
|
|
version: bestModel.version || bestModel.provenance?.version || 'unknown',
|
|
license: bestModel.license || bestModel.provenance?.license || 'unknown',
|
|
digest: bestModel.digest || bestModel.provenance?.digest || 'unknown',
|
|
download_url: bestModel.downloadUrl || bestModel.provenance?.download_url || '',
|
|
provenance: bestModel.provenance || {
|
|
source: bestModel.source || 'unknown',
|
|
registry: bestModel.registry || 'unknown',
|
|
version: bestModel.version || 'unknown',
|
|
license: bestModel.license || 'unknown',
|
|
digest: bestModel.digest || 'unknown'
|
|
}
|
|
};
|
|
|
|
summary.quick_commands.push(command);
|
|
|
|
const isGeneralCategory = ['general', 'coding', 'talking', 'reading'].includes(category);
|
|
const score = bestModel.categoryScore || bestModel.score || 0;
|
|
|
|
if (isGeneralCategory && (score > bestOverallScore || !bestOverallModel)) {
|
|
bestOverallScore = score;
|
|
bestOverallModel = bestModel;
|
|
bestOverallCategory = category;
|
|
}
|
|
}
|
|
});
|
|
|
|
if (bestOverallModel) {
|
|
const command = bestOverallModel.installCommand ||
|
|
bestOverallModel.provenance?.install_command ||
|
|
`ollama pull ${bestOverallModel.model_identifier}`;
|
|
summary.best_overall = {
|
|
name: bestOverallModel.model_name || bestOverallModel.name,
|
|
identifier: bestOverallModel.model_identifier,
|
|
category: bestOverallCategory,
|
|
score: Math.round(bestOverallScore),
|
|
command,
|
|
quantization: bestOverallModel.quantization || bestOverallModel.quant || 'Q4_K_M',
|
|
runtime: bestOverallModel.runtime || bestOverallModel.provenance?.runtime || 'ollama',
|
|
source: bestOverallModel.source || bestOverallModel.provenance?.source || 'unknown',
|
|
registry: bestOverallModel.registry || bestOverallModel.provenance?.registry || 'unknown',
|
|
version: bestOverallModel.version || bestOverallModel.provenance?.version || 'unknown',
|
|
license: bestOverallModel.license || bestOverallModel.provenance?.license || 'unknown',
|
|
digest: bestOverallModel.digest || bestOverallModel.provenance?.digest || 'unknown',
|
|
download_url: bestOverallModel.downloadUrl || bestOverallModel.provenance?.download_url || '',
|
|
provenance: bestOverallModel.provenance || {
|
|
source: bestOverallModel.source || 'unknown',
|
|
registry: bestOverallModel.registry || 'unknown',
|
|
version: bestOverallModel.version || 'unknown',
|
|
license: bestOverallModel.license || 'unknown',
|
|
digest: bestOverallModel.digest || 'unknown'
|
|
}
|
|
};
|
|
}
|
|
|
|
return summary;
|
|
}
|
|
|
|
// ============================================================================
|
|
// PUBLIC API
|
|
// ============================================================================
|
|
|
|
async recommend(category = 'general', options = {}) {
|
|
const result = await this.selectModels(category, options);
|
|
return this.formatRecommendations(result);
|
|
}
|
|
|
|
formatRecommendations(result) {
|
|
const { category, hardware, candidates, total_evaluated } = result;
|
|
|
|
console.log(`\n${category.toUpperCase()} RECOMMENDATIONS`);
|
|
console.log(`Hardware: ${hardware.cpu.cores} cores, ${hardware.memory.totalGB}GB RAM, ${hardware.gpu.type}`);
|
|
console.log(`Evaluated ${total_evaluated} models\n`);
|
|
|
|
if (candidates.length === 0) {
|
|
console.log('❌ No suitable models found for your hardware');
|
|
return result;
|
|
}
|
|
|
|
// Table header
|
|
console.log('┌─────────────────────────────┬────────┬───────┬─────────┬──────────┬───────┬─────────────────────────────┐');
|
|
console.log('│ Model │ Params │ Quant │ Est t/s │ Mem GB │ Score │ Why │');
|
|
console.log('├─────────────────────────────┼────────┼───────┼─────────┼──────────┼───────┼─────────────────────────────┤');
|
|
|
|
candidates.forEach((candidate, index) => {
|
|
const isInstalled = candidate.meta.installed ? 'INSTALLED' : 'CLOUD';
|
|
const name = candidate.meta.name.padEnd(26);
|
|
const params = `${candidate.meta.paramsB}B`.padEnd(5);
|
|
const quant = candidate.quant.padEnd(6);
|
|
const tps = candidate.estTPS.toFixed(1).padStart(7);
|
|
const mem = `${candidate.requiredGB}/${hardware.usableMemGB}`.padEnd(9);
|
|
const score = candidate.score.toFixed(1).padStart(5);
|
|
const why = candidate.rationale.substring(0, 29);
|
|
|
|
console.log(`│ ${isInstalled}${name} │ ${params} │ ${quant} │ ${tps} │ ${mem} │ ${score} │ ${why} │`);
|
|
});
|
|
|
|
console.log('└─────────────────────────────┴────────┴───────┴─────────┴──────────┴───────┴─────────────────────────────┘');
|
|
|
|
// Best pick
|
|
const best = candidates[0];
|
|
console.log(`\nBEST PICK: ${best.meta.name}`);
|
|
console.log(`Command: ollama pull ${best.meta.model_identifier}`);
|
|
console.log(`Why: ${best.rationale}`);
|
|
console.log(`Score: ${best.score} (Q:${best.components.Q} S:${best.components.S} F:${best.components.F} C:${best.components.C})`);
|
|
|
|
return result;
|
|
}
|
|
}
|
|
|
|
module.exports = DeterministicModelSelector;
|