mirror of
https://github.com/Heretek-AI/Strata.git
synced 2026-11-07 10:05:58 -10:00
321 lines
13 KiB
JavaScript
321 lines
13 KiB
JavaScript
/**
|
|
* Hardware detection.
|
|
*
|
|
* Real probes, cross-platform, never fatal: if a command is missing we fall back
|
|
* to Node's os module and a CPU-only verdict. The onboarding wizard shows exactly
|
|
* what was found.
|
|
*/
|
|
import os from 'node:os';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { parseCpuTopology, parseWindowsProcessors, parseNvidiaDevices, detectDrmGpus } from './device-probes.mjs';
|
|
import { CONTEXT_TOKENS, VARIANTS, footprint } from './catalog.mjs';
|
|
import { MODELS_DIR, diskFreeGb, diskTotalGb } from './store.mjs';
|
|
import { probe, round } from './util.mjs';
|
|
import { utf8Script } from './powershell-utf8.mjs';
|
|
|
|
let cached = null;
|
|
let cachedAt = 0;
|
|
|
|
export function invalidate() {
|
|
cached = null;
|
|
}
|
|
|
|
/**
|
|
* The last detected hardware, synchronously.
|
|
*
|
|
* Callers that build config (the expert param list, for one) are synchronous and
|
|
* cannot await a detection pass, but they still need to know how many cores the
|
|
* machine has. Returns null before the first detection, so callers must have a
|
|
* sensible fallback rather than assuming a machine shape.
|
|
*/
|
|
export function cachedHardware() {
|
|
return cached ?? null;
|
|
}
|
|
|
|
function testHardware() {
|
|
const raw = process.env.STRATA_TEST_HARDWARE;
|
|
if (!raw) return null;
|
|
try { const value = JSON.parse(raw); return value && typeof value === 'object' ? value : null; } catch { return null; }
|
|
}
|
|
|
|
export async function detect({ force = false } = {}) {
|
|
if (!force && cached && Date.now() - cachedAt < 30_000) return cached;
|
|
|
|
const platform = process.platform;
|
|
const arch = process.arch;
|
|
// Tests only: STRATA_TEST_HARDWARE='{"totalGb":128,"freeGb":100,"gpus":[...]}' stands in for the probes, so a
|
|
// test about routing or lifecycle does not depend on how much memory the machine running it has.
|
|
const forced = testHardware();
|
|
const totalGb = forced?.totalGb ?? os.totalmem() / 1024 ** 3;
|
|
// os.freemem() ignores reclaimable pages, which reads as "0 GB free" on macOS.
|
|
const freeGb = forced?.freeGb ?? (await availableMemoryGb(platform)) ?? os.freemem() / 1024 ** 3;
|
|
|
|
const cpu = await detectCpu(platform);
|
|
const gpus = forced?.gpus ?? await detectGpus(platform, totalGb);
|
|
const accelerator = pickAccelerator(gpus, cpu, totalGb);
|
|
|
|
const hw = {
|
|
probedAt: new Date().toISOString(),
|
|
host: {
|
|
name: os.hostname().replace(/\.local$/, ''),
|
|
platform: platformLabel(platform),
|
|
release: os.release(),
|
|
arch,
|
|
app: process.version,
|
|
},
|
|
cpu,
|
|
processors: cpu.processors,
|
|
memory: {
|
|
totalGb: round(totalGb / 1, 1),
|
|
freeGb: round(freeGb / 1, 1),
|
|
usedGb: round(totalGb - freeGb, 1),
|
|
pressure: freeGb / totalGb > 0.35 ? 'low' : freeGb / totalGb > 0.18 ? 'medium' : 'high',
|
|
},
|
|
disk: {
|
|
path: MODELS_DIR,
|
|
totalGb: round(diskTotalGb(MODELS_DIR), 0),
|
|
freeGb: round(diskFreeGb(MODELS_DIR), 0),
|
|
modelsDir: MODELS_DIR,
|
|
},
|
|
gpus,
|
|
accelerator,
|
|
checks: [],
|
|
verdict: { ok: true, headline: '', detail: '' },
|
|
recommendedVariantId: '',
|
|
};
|
|
|
|
hw.checks = buildChecks(hw);
|
|
hw.verdict = buildVerdict(hw);
|
|
hw.recommendedVariantId = recommend(hw);
|
|
|
|
cached = hw;
|
|
cachedAt = Date.now();
|
|
return hw;
|
|
}
|
|
|
|
/** Reclaimable memory, not just the free list. */
|
|
export async function availableMemoryGb(platform) {
|
|
if(platform==='linux') {
|
|
try { const raw=await readFile('/proc/meminfo','utf8'); const available=Number(/MemAvailable:\s+(\d+)/.exec(raw)?.[1]); return available>0?available/1024**2:null; } catch { return null; }
|
|
}
|
|
if (platform !== 'darwin') return null;
|
|
const out = await probe('vm_stat', []);
|
|
if (!out) return null;
|
|
const pageSize = Number(/page size of (\d+)/.exec(out)?.[1] ?? 4096);
|
|
const pages = (label) => Number(new RegExp(`Pages ${label}:\\s+(\\d+)`).exec(out)?.[1] ?? 0);
|
|
const reclaimable = pages('free') + pages('inactive') + pages('speculative') + pages('purgeable');
|
|
if (!reclaimable) return null;
|
|
return (reclaimable * pageSize) / 1024 ** 3;
|
|
}
|
|
|
|
async function detectCpu(platform) {
|
|
const logical = os.cpus(), threads = logical.length || 1;
|
|
let model = logical[0]?.model?.trim() ?? 'Unknown processor', ghz=round((logical[0]?.speed??0)/1000,2), processors=[];
|
|
if(platform==='darwin') {
|
|
const [brand,physical,threadCount,packages]=await Promise.all([
|
|
probe('sysctl',['-n','machdep.cpu.brand_string']),probe('sysctl',['-n','hw.physicalcpu']),probe('sysctl',['-n','hw.logicalcpu']),probe('sysctl',['-n','hw.packages'])
|
|
]);
|
|
if(brand)model=brand;
|
|
const cores=Number(physical),count=Number(packages),totalThreads=Number(threadCount)||threads;
|
|
if(cores>0&&count>0&&cores%count===0&&totalThreads%count===0)processors=Array.from({length:count},(_,i)=>({id:`socket-${i}`,socket:String(i),model,cores:cores/count,threads:totalThreads/count,arch:process.arch,ghz,...(count===1?{logicalIds:logical.map((_,index)=>index)}:{})}));
|
|
} else if(platform==='linux') {
|
|
const raw=await probe('lscpu',['-b','-p=CPU,CORE,SOCKET']);
|
|
const names=new Map();
|
|
try { for(const block of (await readFile('/proc/cpuinfo','utf8')).split(/\n\s*\n/)) {
|
|
const fields=Object.fromEntries(block.split('\n').filter(l=>l.includes(':')).map(l=>{const i=l.indexOf(':');return [l.slice(0,i).trim(),l.slice(i+1).trim()];}));
|
|
if(fields['physical id']&&fields['model name'])names.set(fields['physical id'],fields['model name']);
|
|
}}catch{}
|
|
processors=parseCpuTopology(raw,model,process.arch,ghz,names);
|
|
} else if(platform==='win32') {
|
|
const raw=await probe('powershell.exe',['-NoProfile','-NonInteractive','-Command',utf8Script('Get-CimInstance Win32_Processor | Select-Object DeviceID,SocketDesignation,Name,NumberOfCores,NumberOfLogicalProcessors,MaxClockSpeed | ConvertTo-Json -Compress')]);
|
|
processors=parseWindowsProcessors(raw,process.arch);
|
|
}
|
|
const names=[...new Set(processors.map(p=>p.model))];
|
|
return {model:names.length===1?names[0]:names.length>1?names.join(' + '):model,cores:processors.length?processors.reduce((n,p)=>n+p.cores,0):threads,threads:processors.length?processors.reduce((n,p)=>n+p.threads,0):threads,arch:process.arch,ghz,processors,topologyKnown:!!processors.length};
|
|
}
|
|
|
|
async function detectGpus(platform, totalGb) {
|
|
const gpus = [];
|
|
|
|
if (platform === 'darwin') {
|
|
const raw = await probe('system_profiler', ['SPDisplaysDataType', '-json'], 8000);
|
|
if (raw) {
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
const list = parsed?.SPDisplaysDataType ?? [];
|
|
list.forEach((g, i) => {
|
|
const name = g.sppci_model ?? g._name ?? 'Apple GPU';
|
|
if (!name || /display/i.test(name)) return;
|
|
const cores = Number(g.sppci_cores) || 0;
|
|
gpus.push({
|
|
id: `metal:${i}`,
|
|
index: i,
|
|
vendor: /apple/i.test(name) ? 'apple' : /amd|radeon/i.test(name) ? 'amd' : /intel/i.test(name) ? 'intel' : 'unknown',
|
|
name: cores ? `${name} (${cores}-core GPU)` : name,
|
|
// Apple silicon shares system memory; Metal's default wired limit is ~75%.
|
|
vramGb: /apple/i.test(name)&&process.arch==='arm64' ? round(totalGb * 0.75, 1) : (Number.parseFloat(String(g.sppci_vram??g.spdisplays_vram??'')) || 0) * (/MB/i.test(String(g.sppci_vram??g.spdisplays_vram??''))?1/1024:1),
|
|
unified: /apple/i.test(name)&&process.arch==='arm64',
|
|
backend: 'metal',
|
|
});
|
|
});
|
|
} catch {
|
|
/* fall through */
|
|
}
|
|
}
|
|
if (!gpus.length && process.arch === 'arm64') {
|
|
const chip = await probe('sysctl', ['-n', 'machdep.cpu.brand_string']);
|
|
gpus.push({
|
|
id: 'metal:0',
|
|
index: 0,
|
|
vendor: 'apple',
|
|
name: chip || 'Apple silicon GPU',
|
|
vramGb: round(totalGb * 0.75, 1),
|
|
unified: true,
|
|
backend: 'metal',
|
|
});
|
|
}
|
|
}
|
|
|
|
const smi = await probe('nvidia-smi', ['--query-gpu=index,uuid,pci.bus_id,name,memory.total,driver_version','--format=csv,noheader,nounits']);
|
|
gpus.push(...parseNvidiaDevices(smi));
|
|
if(platform==='linux') {
|
|
const drm=await detectDrmGpus();
|
|
for(const gpu of drm) {
|
|
const existing=gpus.find(g=>g.busId?.replace(/^00000000:/,'0000:')===gpu.busId);
|
|
if(existing)existing.sensorPath=gpu.sensorPath;else gpus.push(gpu);
|
|
}
|
|
}
|
|
if(platform==='win32') {
|
|
const raw=await probe('powershell.exe',['-NoProfile','-NonInteractive','-Command',utf8Script('Get-CimInstance Win32_VideoController | Select-Object Name,PNPDeviceID | ConvertTo-Json -Compress')]);
|
|
try {const parsed=JSON.parse(raw);for(const row of Array.isArray(parsed)?parsed:[parsed]) {
|
|
if(!row?.Name||(/nvidia/i.test(row.Name)&&gpus.some(g=>g.vendor==='nvidia')))continue;
|
|
const vendor=/amd|radeon/i.test(row.Name)?'amd':/intel/i.test(row.Name)?'intel':'unknown';
|
|
gpus.push({id:String(row.PNPDeviceID),index:gpus.length,vendor,name:row.Name,vramGb:0,unified:false,backend:'vulkan'});
|
|
}}catch{}
|
|
}
|
|
gpus.forEach((g,i)=>{g.index=i;});
|
|
|
|
return gpus;
|
|
}
|
|
|
|
function pickAccelerator(gpus, cpu, totalGb) {
|
|
const best = [...gpus]
|
|
.filter((g) => g.backend !== 'cpu')
|
|
.sort((a, b) => b.vramGb - a.vramGb)[0];
|
|
if (best) {
|
|
return {
|
|
backend: best.backend,
|
|
label: best.name,
|
|
usableVramGb: best.vramGb,
|
|
unified: best.unified,
|
|
};
|
|
}
|
|
return {
|
|
backend: 'cpu',
|
|
label: `${cpu.model.split('@')[0].trim()} (CPU only)`,
|
|
usableVramGb: round(totalGb * 0.5, 1),
|
|
unified: true,
|
|
};
|
|
}
|
|
|
|
function buildChecks(hw) {
|
|
const checks = [];
|
|
const accel = hw.accelerator;
|
|
|
|
checks.push(
|
|
accel.backend === 'cpu'
|
|
? {
|
|
id: 'gpu',
|
|
label: 'GPU and driver',
|
|
state: 'warn',
|
|
detail: 'No GPU accelerator found — Strata will run on the CPU',
|
|
value: accel.label,
|
|
}
|
|
: {
|
|
id: 'gpu',
|
|
label: 'GPU and driver',
|
|
state: 'pass',
|
|
detail: 'Compatible GPU found',
|
|
value: `${accel.label} · ${accel.backend}`,
|
|
},
|
|
);
|
|
|
|
const total = hw.memory.totalGb;
|
|
checks.push(
|
|
total >= 32
|
|
? { id: 'memory', label: 'System memory', state: 'pass', detail: 'Enough for recommended model', value: `${Math.round(total)} GB` }
|
|
: total >= 16
|
|
? { id: 'memory', label: 'System memory', state: 'pass', detail: 'Enough for the smallest model', value: `${Math.round(total)} GB` }
|
|
: { id: 'memory', label: 'System memory', state: 'fail', detail: 'Below the 16 GB minimum', value: `${Math.round(total)} GB` },
|
|
);
|
|
|
|
const cores = hw.cpu.cores;
|
|
checks.push(
|
|
cores >= 8
|
|
? { id: 'cpu', label: 'CPU', state: 'pass', detail: 'Supported', value: `${cores} cores` }
|
|
: cores >= 4
|
|
? { id: 'cpu', label: 'CPU', state: 'pass', detail: 'Supported, slower prompt reading', value: `${cores} cores` }
|
|
: { id: 'cpu', label: 'CPU', state: 'warn', detail: 'Few cores — expect slow prompt reading', value: `${cores} cores` },
|
|
);
|
|
|
|
const free = hw.disk.freeGb;
|
|
checks.push(
|
|
free >= 60
|
|
? { id: 'storage', label: 'Free storage', state: 'pass', detail: 'Space available', value: `${Math.round(free)} GB free` }
|
|
: free >= 25
|
|
? { id: 'storage', label: 'Free storage', state: 'pass', detail: 'Enough for one model', value: `${Math.round(free)} GB free` }
|
|
: { id: 'storage', label: 'Free storage', state: 'warn', detail: 'Tight — pick the smallest variant', value: `${Math.round(free)} GB free` },
|
|
);
|
|
|
|
return checks;
|
|
}
|
|
|
|
function buildVerdict(hw) {
|
|
const failed = hw.checks.filter((c) => c.state === 'fail');
|
|
const warned = hw.checks.filter((c) => c.state === 'warn');
|
|
if (failed.length) {
|
|
return {
|
|
ok: false,
|
|
headline: 'This computer is below the minimum.',
|
|
detail: `${failed.map((f) => f.label).join(' and ')} need attention before a model will fit.`,
|
|
};
|
|
}
|
|
if (warned.length) {
|
|
return {
|
|
ok: true,
|
|
headline: 'This computer can run Strata.',
|
|
detail: `${warned.map((w) => w.label).join(' and ')} ${warned.length > 1 ? 'are' : 'is'} a limit — Strata will pick a lighter model.`,
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
headline: 'This computer can run Strata comfortably.',
|
|
detail: 'Everything needed for the recommended model is present.',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Recommend the heaviest variant that still fits fast memory at the default
|
|
* context, using the same footprint maths the tuning screen shows. When nothing
|
|
* fits, fall back to the smallest tier rather than the biggest.
|
|
*/
|
|
function recommend(hw) {
|
|
const usable = hw.accelerator.unified
|
|
? hw.memory.totalGb * 0.72
|
|
: hw.accelerator.usableVramGb + hw.memory.totalGb * 0.35;
|
|
const ctx = CONTEXT_TOKENS.recommended;
|
|
|
|
const viable = VARIANTS.map((v) => ({ v, fp: footprint(v, ctx) }))
|
|
.filter(({ fp }) => fp.totalGb <= usable)
|
|
.sort((a, b) => b.fp.totalGb - a.fp.totalGb);
|
|
|
|
if (viable.length) return viable[0].v.id;
|
|
|
|
const smallest = [...VARIANTS].sort((a, b) => footprint(a, ctx).totalGb - footprint(b, ctx).totalGb)[0];
|
|
return smallest?.id ?? VARIANTS[0].id;
|
|
}
|
|
|
|
function platformLabel(platform) {
|
|
return platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform === 'linux' ? 'Linux' : platform;
|
|
}
|