/** * RuvLTRA Model Registry and Downloader * * Automatically downloads GGUF models from HuggingFace Hub. * * @example * ```typescript * import { ModelDownloader, RUVLTRA_MODELS } from '@ruvector/ruvllm'; * * // Download the Claude Code optimized model * const downloader = new ModelDownloader(); * const modelPath = await downloader.download('claude-code'); * * // Or download all models * await downloader.downloadAll(); * ``` */ import { createWriteStream, existsSync, mkdirSync, statSync, unlinkSync, renameSync } from 'fs'; import { join, dirname } from 'path'; import { homedir } from 'os'; import { pipeline } from 'stream/promises'; import { createHash } from 'crypto'; /** Model information from HuggingFace */ export interface ModelInfo { /** Model identifier */ id: string; /** Display name */ name: string; /** Model filename on HuggingFace */ filename: string; /** Model size in bytes */ sizeBytes: number; /** Model size (human readable) */ size: string; /** Parameter count */ parameters: string; /** Use case description */ useCase: string; /** Quantization type */ quantization: string; /** Context window size */ contextLength: number; /** HuggingFace download URL */ url: string; } /** Download progress callback */ export type ProgressCallback = (progress: DownloadProgress) => void; /** Download progress information */ export interface DownloadProgress { /** Model being downloaded */ modelId: string; /** Bytes downloaded so far */ downloaded: number; /** Total bytes to download */ total: number; /** Download percentage (0-100) */ percent: number; /** Download speed in bytes per second */ speedBps: number; /** Estimated time remaining in seconds */ etaSeconds: number; } /** Download options */ export interface DownloadOptions { /** Directory to save models (default: ~/.ruvllm/models) */ modelsDir?: string; /** Force re-download even if file exists */ force?: boolean; /** Progress callback */ onProgress?: ProgressCallback; /** Verify file integrity after download */ verify?: boolean; } /** HuggingFace repository */ const HF_REPO = 'ruv/ruvltra'; const HF_BASE_URL = `https://huggingface.co/${HF_REPO}/resolve/main`; /** Available RuvLTRA models */ export const RUVLTRA_MODELS: Record = { 'claude-code': { id: 'claude-code', name: 'RuvLTRA Claude Code', filename: 'ruvltra-claude-code-0.5b-q4_k_m.gguf', sizeBytes: 398_000_000, size: '398 MB', parameters: '0.5B', useCase: 'Claude Code workflows, agentic coding', quantization: 'Q4_K_M', contextLength: 4096, url: `${HF_BASE_URL}/ruvltra-claude-code-0.5b-q4_k_m.gguf`, }, 'small': { id: 'small', name: 'RuvLTRA Small', filename: 'ruvltra-small-0.5b-q4_k_m.gguf', sizeBytes: 398_000_000, size: '398 MB', parameters: '0.5B', useCase: 'Edge devices, IoT, resource-constrained environments', quantization: 'Q4_K_M', contextLength: 4096, url: `${HF_BASE_URL}/ruvltra-small-0.5b-q4_k_m.gguf`, }, 'medium': { id: 'medium', name: 'RuvLTRA Medium', filename: 'ruvltra-medium-1.1b-q4_k_m.gguf', sizeBytes: 669_000_000, size: '669 MB', parameters: '1.1B', useCase: 'General purpose, balanced performance', quantization: 'Q4_K_M', contextLength: 8192, url: `${HF_BASE_URL}/ruvltra-medium-1.1b-q4_k_m.gguf`, }, }; /** Model aliases for convenience */ export const MODEL_ALIASES: Record = { 'cc': 'claude-code', 'claudecode': 'claude-code', 'claude': 'claude-code', 's': 'small', 'sm': 'small', 'm': 'medium', 'med': 'medium', 'default': 'claude-code', }; /** * Get the default models directory */ export function getDefaultModelsDir(): string { return join(homedir(), '.ruvllm', 'models'); } /** * Resolve model ID from alias or direct ID */ export function resolveModelId(modelIdOrAlias: string): string | null { const normalized = modelIdOrAlias.toLowerCase().trim(); // Direct match if (RUVLTRA_MODELS[normalized]) { return normalized; } // Alias match if (MODEL_ALIASES[normalized]) { return MODEL_ALIASES[normalized]; } return null; } /** * Get model info by ID or alias */ export function getModelInfo(modelIdOrAlias: string): ModelInfo | null { const id = resolveModelId(modelIdOrAlias); return id ? RUVLTRA_MODELS[id] : null; } /** * List all available models */ export function listModels(): ModelInfo[] { return Object.values(RUVLTRA_MODELS); } /** * Model downloader for RuvLTRA GGUF models */ export class ModelDownloader { private modelsDir: string; constructor(modelsDir?: string) { this.modelsDir = modelsDir || getDefaultModelsDir(); } /** * Get the path where a model would be saved */ getModelPath(modelIdOrAlias: string): string | null { const model = getModelInfo(modelIdOrAlias); if (!model) return null; return join(this.modelsDir, model.filename); } /** * Check if a model is already downloaded */ isDownloaded(modelIdOrAlias: string): boolean { const path = this.getModelPath(modelIdOrAlias); if (!path) return false; if (!existsSync(path)) return false; // Verify size matches expected const model = getModelInfo(modelIdOrAlias); if (!model) return false; const stats = statSync(path); // Allow 5% variance for size check const minSize = model.sizeBytes * 0.95; return stats.size >= minSize; } /** * Get download status for all models */ getStatus(): { model: ModelInfo; downloaded: boolean; path: string }[] { return listModels().map(model => ({ model, downloaded: this.isDownloaded(model.id), path: this.getModelPath(model.id)!, })); } /** * Download a model from HuggingFace */ async download( modelIdOrAlias: string, options: DownloadOptions = {} ): Promise { const model = getModelInfo(modelIdOrAlias); if (!model) { const available = listModels().map(m => m.id).join(', '); throw new Error( `Unknown model: ${modelIdOrAlias}. Available models: ${available}` ); } const destDir = options.modelsDir || this.modelsDir; const destPath = join(destDir, model.filename); // Check if already downloaded if (!options.force && this.isDownloaded(model.id)) { return destPath; } // Ensure directory exists if (!existsSync(destDir)) { mkdirSync(destDir, { recursive: true }); } // Download with progress tracking const tempPath = `${destPath}.tmp`; let startTime = Date.now(); let lastProgressTime = startTime; let lastDownloaded = 0; try { // Use dynamic import for node-fetch if native fetch not available const fetchFn = globalThis.fetch || (await import('node:https')).default; const response = await fetch(model.url, { headers: { 'User-Agent': 'RuvLLM/2.3.0', }, }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const contentLength = parseInt( response.headers.get('content-length') || String(model.sizeBytes) ); // Create write stream const fileStream = createWriteStream(tempPath); let downloaded = 0; // Stream with progress const reader = response.body?.getReader(); if (!reader) { throw new Error('Response body is not readable'); } while (true) { const { done, value } = await reader.read(); if (done) break; downloaded += value.length; fileStream.write(value); // Report progress if (options.onProgress) { const now = Date.now(); const elapsed = (now - lastProgressTime) / 1000; const bytesThisInterval = downloaded - lastDownloaded; const speedBps = elapsed > 0 ? bytesThisInterval / elapsed : 0; const remaining = contentLength - downloaded; const etaSeconds = speedBps > 0 ? remaining / speedBps : 0; options.onProgress({ modelId: model.id, downloaded, total: contentLength, percent: Math.round((downloaded / contentLength) * 100), speedBps, etaSeconds, }); lastProgressTime = now; lastDownloaded = downloaded; } } fileStream.end(); // Wait for file to be fully written await new Promise((resolve, reject) => { fileStream.on('finish', resolve); fileStream.on('error', reject); }); // Move temp file to final destination if (existsSync(destPath)) { unlinkSync(destPath); } renameSync(tempPath, destPath); return destPath; } catch (error) { // Clean up temp file on error if (existsSync(tempPath)) { try { unlinkSync(tempPath); } catch {} } throw error; } } /** * Download all available models */ async downloadAll(options: DownloadOptions = {}): Promise { const paths: string[] = []; for (const model of listModels()) { const path = await this.download(model.id, options); paths.push(path); } return paths; } /** * Delete a downloaded model */ delete(modelIdOrAlias: string): boolean { const path = this.getModelPath(modelIdOrAlias); if (!path || !existsSync(path)) { return false; } unlinkSync(path); return true; } /** * Delete all downloaded models */ deleteAll(): number { let count = 0; for (const model of listModels()) { if (this.delete(model.id)) { count++; } } return count; } } export default ModelDownloader;