import { readdir, readFile, stat } from "node:fs/promises"; import { join } from "node:path"; import { Effect, Schema } from "effect"; 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 class ModelBrowserError extends Schema.TaggedErrorClass()( "ModelBrowserError", { operation: Schema.Literals(["read", "stat", "scan"]), path: Schema.String, message: Schema.String, source: Schema.Unknown, }, ) {} const modelBrowserError = ( operation: ModelBrowserError["operation"], path: string, source: unknown, ): ModelBrowserError => new ModelBrowserError({ operation, path, message: `Model ${operation} failed for ${path}: ${String(source)}`, source, }); const isWeightFile = (name: string): boolean => MODEL_BROWSER_WEIGHT_EXTENSIONS.some((extension) => name.toLowerCase().endsWith(extension)); export const looksLikeModelDirectory = (path: string): Effect.Effect => Effect.tryPromise({ try: () => readdir(path, { withFileTypes: true }), catch: (source) => modelBrowserError("scan", path, source), }).pipe( Effect.map( (entries) => MODEL_BROWSER_CONFIG_FILENAMES.some((configName) => entries.some((entry) => entry.isFile() && entry.name === configName), ) || entries.some((entry) => entry.isFile() && isWeightFile(entry.name)), ), ); export const inferQuantization = (name: string): string | undefined => { const lower = name.toLowerCase(); return MODEL_QUANTIZATION_SIGNATURES.find((value) => lower.includes(value)); }; export const readConfigMetadata = ( modelDirectory: string, ): Effect.Effect< { architecture: string | null; context_length: number | null }, ModelBrowserError > => { const configPath = join(modelDirectory, "config.json"); return Effect.tryPromise({ try: () => readFile(configPath, "utf-8"), catch: (source) => modelBrowserError("read", configPath, source), }).pipe( Effect.flatMap((content) => Effect.try({ try: () => JSON.parse(content) as Record, catch: (source) => modelBrowserError("read", configPath, source), }), ), Effect.map((parsed) => { const architectures = parsed["architectures"]; const architecture = Array.isArray(architectures) && architectures.length > 0 ? String(architectures[0]) : null; const raw = parsed["max_position_embeddings"] ?? parsed["max_seq_len"] ?? parsed["seq_length"] ?? parsed["n_ctx"]; const contextLength = typeof raw === "number" ? raw : typeof raw === "string" && /^\d+$/.test(raw) ? Number(raw) : null; return { architecture, context_length: contextLength }; }), ); }; export const estimateWeightsSizeBytes = ( modelDirectory: string, recursive: boolean, ): Effect.Effect => Effect.gen(function* () { const rootStats = yield* Effect.tryPromise({ try: () => stat(modelDirectory), catch: (source) => modelBrowserError("stat", modelDirectory, source), }); if (rootStats.isFile()) { return isWeightFile(modelDirectory) && rootStats.size > 0 ? rootStats.size : null; } const entries = yield* Effect.tryPromise({ try: () => readdir(modelDirectory, { withFileTypes: true }), catch: (source) => modelBrowserError("scan", modelDirectory, source), }); let total = 0; for (const entry of entries) { const path = join(modelDirectory, entry.name); if (entry.isDirectory() && recursive) { total += (yield* estimateWeightsSizeBytes(path, true).pipe( Effect.catch(() => Effect.succeed(null)), )) ?? 0; } else if (entry.isFile() && isWeightFile(entry.name)) { total += yield* Effect.tryPromise({ try: async () => (await stat(path)).size, catch: (source) => modelBrowserError("stat", path, source), }).pipe(Effect.catch(() => Effect.succeed(0))); } } return total || null; }); export const discoverModelDirectories = ( roots: string[], maxDepth = 1, maxModels = 500, ): Effect.Effect => Effect.gen(function* () { const discovered: string[] = []; const seen = new Set(); const queue = roots.filter(Boolean).map((path) => ({ path, depth: 0 })); while (queue.length > 0 && discovered.length < maxModels) { const entry = queue.shift(); if (!entry || seen.has(entry.path)) continue; seen.add(entry.path); const modelDirectory = yield* looksLikeModelDirectory(entry.path).pipe( Effect.catch(() => Effect.succeed(false)), ); if (modelDirectory) { discovered.push(entry.path); continue; } if (entry.depth >= maxDepth) continue; const children = yield* Effect.tryPromise({ try: () => readdir(entry.path, { withFileTypes: true }), catch: () => null, }).pipe(Effect.catch(() => Effect.succeed(null))); if (!children) continue; for (const child of children) { if (child.isDirectory() && !child.name.startsWith(".")) { queue.push({ path: join(entry.path, child.name), depth: entry.depth + 1 }); } } } return discovered; }); export const buildModelInfo = ( modelDirectory: string, recipeIds: string[] = [], ): Effect.Effect => Effect.gen(function* () { const metadata = yield* readConfigMetadata(modelDirectory).pipe( Effect.catch(() => Effect.succeed({ architecture: null, context_length: null })), ); const modifiedAt = yield* Effect.tryPromise({ try: async () => (await stat(modelDirectory)).mtimeMs, catch: () => null, }).pipe(Effect.catch(() => Effect.succeed(null))); const name = modelDirectory.split("/").pop() ?? modelDirectory; const size = yield* estimateWeightsSizeBytes(modelDirectory, false).pipe( Effect.catch(() => Effect.succeed(null)), ); return { name, path: modelDirectory, size_bytes: size, modified_at: modifiedAt, architecture: metadata.architecture, quantization: inferQuantization(name) ?? null, context_length: metadata.context_length, recipe_ids: [...new Set(recipeIds)].sort(), has_recipe: recipeIds.length > 0, }; });