import { statSync, existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import type { ModelInfo } from "./types"; const MODEL_BROWSER_WEIGHT_EXTENSIONS = [".safetensors", ".bin", ".gguf"] as const; const MODEL_BROWSER_CONFIG_FILENAMES = ["config.json"] as const; const MODEL_QUANTIZATION_SIGNATURES = [ "awq", "gptq", "gguf", "fp16", "bf16", "int8", "int4", "w4a16", "w8a16", ]; export const looksLikeModelDirectory = (path: string): boolean => { if (!existsSync(path)) { return false; } try { const entries = readdirSync(path, { withFileTypes: true }); for (const configName of MODEL_BROWSER_CONFIG_FILENAMES) { if (entries.some((entry) => entry.isFile() && entry.name === configName)) { return true; } } return entries.some( (entry) => entry.isFile() && MODEL_BROWSER_WEIGHT_EXTENSIONS.some((extension) => entry.name.toLowerCase().endsWith(extension), ), ); } catch { return false; } }; export const inferQuantization = (name: string): string | undefined => { const lower = name.toLowerCase(); const candidates = MODEL_QUANTIZATION_SIGNATURES; return candidates.find((value) => lower.includes(value)); }; export const readConfigMetadata = ( modelDirectory: string, ): { architecture: string | null; context_length: number | null } => { const configPath = join(modelDirectory, "config.json"); if (!existsSync(configPath)) { return { architecture: null, context_length: null }; } try { const content = readFileSync(configPath, "utf-8"); const parsed = JSON.parse(content) as Record; const architectures = parsed["architectures"]; const architecture = Array.isArray(architectures) && architectures.length > 0 ? String(architectures[0]) : null; const contextLengthRaw = parsed["max_position_embeddings"] || parsed["max_seq_len"] || parsed["seq_length"] || parsed["n_ctx"]; const contextLength = typeof contextLengthRaw === "number" ? contextLengthRaw : typeof contextLengthRaw === "string" && /^\d+$/.test(contextLengthRaw) ? Number(contextLengthRaw) : null; return { architecture, context_length: contextLength }; } catch { return { architecture: null, context_length: null }; } }; export const estimateWeightsSizeBytes = ( modelDirectory: string, recursive: boolean, ): number | null => { let total = 0; try { const entries = readdirSync(modelDirectory, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && recursive) { const nested = estimateWeightsSizeBytes(join(modelDirectory, entry.name), true); total += nested ?? 0; continue; } if (!entry.isFile()) { continue; } if ( !MODEL_BROWSER_WEIGHT_EXTENSIONS.some((extension) => entry.name.toLowerCase().endsWith(extension), ) ) { continue; } try { const stats = statSync(join(modelDirectory, entry.name)); total += stats.size; } catch { continue; } } } catch { return null; } return total || null; }; export const discoverModelDirectories = ( roots: string[], maxDepth = 1, maxModels = 500, ): string[] => { const discovered: string[] = []; const seen = new Set(); const queue: Array<{ path: string; depth: number }> = roots .filter((root) => Boolean(root)) .map((root) => ({ path: root, depth: 0 })); while (queue.length > 0 && discovered.length < maxModels) { const entry = queue.shift(); if (!entry) { break; } const current = entry.path; if (seen.has(current)) { continue; } seen.add(current); if (looksLikeModelDirectory(current)) { discovered.push(current); continue; } if (entry.depth >= maxDepth) { continue; } try { const children = readdirSync(current, { withFileTypes: true }); for (const child of children) { if (!child.isDirectory() || child.name.startsWith(".")) { continue; } queue.push({ path: join(current, child.name), depth: entry.depth + 1 }); } } catch { continue; } } return discovered; }; export const buildModelInfo = async ( modelDirectory: string, recipeIds: string[] = [], ): Promise => { const metadata = await readConfigMetadata(modelDirectory); let modifiedAt: number | undefined; try { modifiedAt = statSync(modelDirectory).mtimeMs; } catch { modifiedAt = undefined; } const name = modelDirectory.split("/").pop() ?? modelDirectory; return { name, path: modelDirectory, size_bytes: estimateWeightsSizeBytes(modelDirectory, false), modified_at: modifiedAt ?? null, architecture: metadata.architecture, quantization: inferQuantization(name) ?? null, context_length: metadata.context_length, recipe_ids: [...new Set(recipeIds)].sort(), has_recipe: recipeIds.length > 0, }; };