ml-model-loader/bin/model-loader.ts
Lilith bbbccd685b feat: universal ML model support with auto-discovery
Add support for all ML model types beyond GGUF:
- New discovery module for auto-scanning model directories
- Detect formats: GGUF, safetensors, ONNX, PyTorch, diffusion pipelines
- CLI commands: discover, scan, sync for manifest management
- Manifest v2.0 with format field, directory support, file lists

Python loaders (v2.0.0):
- ONNXLoader with CUDA/TensorRT execution providers
- WhisperLoader for faster-whisper with transcribe/stream
- get_auto_loader() for automatic backend selection

Breaking: Manifest schema upgraded to v2.0 (auto-migrates v1.x on load)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 15:21:52 -08:00

553 lines
17 KiB
JavaScript

#!/usr/bin/env node
/**
* @lilith/ml-model-loader CLI
*
* Command-line interface for model loading and management.
* Used by Python wrapper via subprocess.
*/
import { program } from "commander";
import {
ModelLoader,
saveManifest,
createDefaultManifest,
isRsyncAvailable,
isScpAvailable,
testSSHConnection,
} from "../src/index.js";
import {
scanDirectory,
diffManifests,
applyDiff,
summarizeDiff,
} from "../src/discovery/index.js";
// Types are available from discovery module when needed
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
program
.name("model-loader")
.description("Lilith Model Loader - fetch and cache ML models")
.version("1.0.0");
// ensure - ensure a model is cached
program
.command("ensure <model-id>")
.description("Ensure model is cached locally (fetch if needed)")
.option("--cache-dir <dir>", "Cache directory")
.option("--manifest <path>", "Manifest file path")
.option("--ssh-key <path>", "SSH key file for remote access")
.option("--timeout <ms>", "Timeout for remote operations", parseInt)
.option("--json", "Output JSON (for programmatic use)")
.option("--verbose", "Enable verbose logging")
.action(async (modelId: string, options) => {
try {
const loader = new ModelLoader({
cacheDir: options.cacheDir,
manifest: options.manifest,
sshAuth: options.sshKey ? { keyPath: options.sshKey } : undefined,
timeout: options.timeout,
verbose: options.verbose && !options.json,
onProgress: options.verbose && !options.json
? (p) => {
process.stdout.write(
`\r[${p.percentComplete}%] ${formatBytes(p.bytesTransferred)} - ${p.speed} ETA ${p.eta}`
);
}
: undefined,
});
const result = await loader.ensureModel(modelId);
if (options.verbose && !options.json) {
process.stdout.write("\n");
}
if (options.json) {
console.log(JSON.stringify(result));
} else {
console.log(`Path: ${result.path}`);
console.log(`Source: ${result.source}${result.method ? ` (${result.method})` : ""}`);
console.log(`Duration: ${result.duration}ms`);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (options.json) {
console.log(JSON.stringify({ error: message }));
process.exit(1);
} else {
console.error(`Error: ${message}`);
process.exit(1);
}
}
});
// list - list models from manifest
program
.command("list")
.description("List available models from manifest")
.option("--manifest <path>", "Manifest file path")
.option("--json", "Output JSON")
.action((options) => {
try {
const loader = new ModelLoader({ manifest: options.manifest });
const models = loader.listModels();
if (options.json) {
const output = models.map(([id, entry]) => ({ id, ...entry }));
console.log(JSON.stringify(output));
} else {
if (models.length === 0) {
console.log("No models in manifest");
return;
}
console.log("Available models:");
for (const [id, entry] of models) {
const cached = loader.isCached(id) ? " [cached]" : "";
console.log(` ${id}: ${entry.name} (${formatBytes(entry.size)})${cached}`);
}
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: ${message}`);
process.exit(1);
}
});
// cached - list cached models
program
.command("cached")
.description("List cached models")
.option("--cache-dir <dir>", "Cache directory")
.option("--manifest <path>", "Manifest file path")
.option("--json", "Output JSON")
.action((options) => {
try {
const loader = new ModelLoader({
cacheDir: options.cacheDir,
manifest: options.manifest,
});
const cached = loader.listCached();
if (options.json) {
console.log(JSON.stringify(cached));
} else {
if (cached.length === 0) {
console.log("No cached models");
return;
}
console.log("Cached models:");
for (const model of cached) {
const idStr = model.modelId ? ` (${model.modelId})` : "";
console.log(` ${model.name}${idStr}: ${formatBytes(model.size)}`);
}
const stats = loader.getCacheStats();
console.log(`\nTotal: ${formatBytes(stats.totalSize)} in ${stats.count} files`);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: ${message}`);
process.exit(1);
}
});
// remove - remove a model from cache
program
.command("remove <model-id>")
.description("Remove a model from cache")
.option("--cache-dir <dir>", "Cache directory")
.option("--manifest <path>", "Manifest file path")
.action((modelId: string, options) => {
try {
const loader = new ModelLoader({
cacheDir: options.cacheDir,
manifest: options.manifest,
verbose: true,
});
if (!loader.isCached(modelId)) {
console.log(`Model not cached: ${modelId}`);
return;
}
loader.removeFromCache(modelId);
console.log(`Removed: ${modelId}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: ${message}`);
process.exit(1);
}
});
// clear - clear entire cache
program
.command("clear")
.description("Clear entire model cache")
.option("--cache-dir <dir>", "Cache directory")
.option("--force", "Skip confirmation")
.action(async (options) => {
try {
const loader = new ModelLoader({
cacheDir: options.cacheDir,
verbose: true,
});
const stats = loader.getCacheStats();
if (stats.count === 0) {
console.log("Cache is already empty");
return;
}
if (!options.force) {
console.log(`About to clear ${stats.count} models (${formatBytes(stats.totalSize)})`);
console.log("Use --force to confirm");
return;
}
loader.clearCache();
console.log(`Cleared ${stats.count} models`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: ${message}`);
process.exit(1);
}
});
// info - show model info
program
.command("info <model-id>")
.description("Show detailed model information")
.option("--manifest <path>", "Manifest file path")
.option("--cache-dir <dir>", "Cache directory")
.option("--json", "Output JSON")
.action((modelId: string, options) => {
try {
const loader = new ModelLoader({
cacheDir: options.cacheDir,
manifest: options.manifest,
});
const entry = loader.getModelEntry(modelId);
if (!entry) {
console.error(`Model not found in manifest: ${modelId}`);
process.exit(1);
}
const cached = loader.isCached(modelId);
const cachedPath = loader.getCachedPath(modelId);
if (options.json) {
console.log(JSON.stringify({ id: modelId, ...entry, cached, cachedPath }));
} else {
console.log(`ID: ${modelId}`);
console.log(`Name: ${entry.name}`);
console.log(`Path: ${entry.path}`);
console.log(`Size: ${formatBytes(entry.size)}`);
console.log(`Category: ${entry.category}`);
if (entry.type) console.log(`Type: ${entry.type}`);
if (entry.quantization) console.log(`Quantization: ${entry.quantization}`);
if (entry.contextSize) console.log(`Context Size: ${entry.contextSize}`);
if (entry.description) console.log(`Description: ${entry.description}`);
if (entry.source) console.log(`Source: ${entry.source}`);
console.log(`Cached: ${cached ? `Yes (${cachedPath})` : "No"}`);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: ${message}`);
process.exit(1);
}
});
// init - initialize manifest
program
.command("init")
.description("Initialize a new manifest file")
.option("--manifest <path>", "Manifest file path")
.option("--force", "Overwrite existing manifest")
.action((options) => {
try {
const path = options.manifest;
// Check if manifest already exists
const loader = new ModelLoader({ manifest: path });
const existing = loader.getManifest();
if (Object.keys(existing.models).length > 0 && !options.force) {
console.log("Manifest already exists with models. Use --force to overwrite.");
return;
}
const manifest = createDefaultManifest();
saveManifest(manifest, path);
console.log(`Created manifest at: ${loader.getManifestPath()}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: ${message}`);
process.exit(1);
}
});
// status - check system status
program
.command("status")
.description("Check system status (tools, connectivity)")
.option("--manifest <path>", "Manifest file path")
.action(async (options) => {
try {
console.log("Checking system status...\n");
// Check tools
const rsync = await isRsyncAvailable();
const scp = await isScpAvailable();
console.log(`Tools:`);
console.log(` rsync: ${rsync ? "available" : "not found"}`);
console.log(` scp: ${scp ? "available" : "not found"}`);
// Check manifest
const loader = new ModelLoader({ manifest: options.manifest });
const manifest = loader.getManifest();
console.log(`\nManifest: ${loader.getManifestPath()}`);
console.log(` Models: ${Object.keys(manifest.models).length}`);
console.log(` Remote: ${manifest.remote.host} (${manifest.remote.method})`);
// Check SSH connectivity
console.log(`\nSSH Connectivity:`);
const sshOk = await testSSHConnection(manifest.remote.host);
console.log(` ${manifest.remote.host}: ${sshOk ? "connected" : "failed"}`);
// Check cache
const stats = loader.getCacheStats();
console.log(`\nCache: ${stats.cacheDir}`);
console.log(` Files: ${stats.count}`);
console.log(` Size: ${formatBytes(stats.totalSize)}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: ${message}`);
process.exit(1);
}
});
// discover - scan directory and preview what would be added
program
.command("discover <directory>")
.description("Scan directory and show discovered models")
.option("--manifest <path>", "Manifest file path")
.option("--category <cat>", "Filter by category")
.option("--format <fmt>", "Filter by format")
.option("--json", "Output JSON")
.option("--verbose", "Show detailed information")
.action(async (directory: string, options) => {
try {
console.log(`Scanning ${directory}...\n`);
const result = await scanDirectory({
rootPath: directory,
onProgress: options.verbose
? (p) => {
process.stdout.write(
`\r[${p.phase}] ${p.current} items processed...`
);
}
: undefined,
});
if (options.verbose) {
process.stdout.write("\n\n");
}
// Apply filters
let models = result.models;
if (options.category) {
models = models.filter((m) => m.category === options.category);
}
if (options.format) {
models = models.filter((m) => m.format === options.format);
}
if (options.json) {
console.log(JSON.stringify({ models, stats: result.stats }, null, 2));
return;
}
// Print summary by category
console.log("Found models by category:");
for (const [cat, count] of Object.entries(result.stats.byCategory)) {
console.log(` ${cat}: ${count}`);
}
console.log("\nFound models by format:");
for (const [fmt, count] of Object.entries(result.stats.byFormat)) {
console.log(` ${fmt}: ${count}`);
}
console.log(`\nTotal: ${models.length} models (${formatBytes(result.stats.totalSize)})`);
console.log(`Scan time: ${result.stats.duration}ms`);
if (result.errors.length > 0) {
console.log(`\nWarnings: ${result.errors.length} errors encountered`);
if (options.verbose) {
for (const err of result.errors.slice(0, 10)) {
console.log(` ${err.path}: ${err.error}`);
}
}
}
// Compare with manifest
const loader = new ModelLoader({ manifest: options.manifest });
const manifest = loader.getManifest();
const diff = diffManifests(models, manifest);
console.log(`\nManifest comparison:`);
console.log(` New models: ${diff.added.length}`);
console.log(` Updated: ${diff.updated.length}`);
console.log(` Unchanged: ${diff.unchanged.length}`);
if (diff.added.length > 0) {
console.log(`\nUse 'model-loader scan ${directory}' to update manifest.`);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: ${message}`);
process.exit(1);
}
});
// scan - scan and update manifest
program
.command("scan <directory>")
.description("Scan directory and update manifest")
.option("--manifest <path>", "Manifest file path")
.option("--dry-run", "Show changes without applying")
.option("--replace", "Replace existing entries instead of merging")
.option("--prune", "Remove entries not found on disk")
.option("--json", "Output changes as JSON")
.action(async (directory: string, options) => {
try {
console.log(`Scanning ${directory}...`);
const result = await scanDirectory({
rootPath: directory,
onProgress: (p) => {
process.stdout.write(`\r[${p.phase}] ${p.current} items...`);
},
});
process.stdout.write("\n");
const loader = new ModelLoader({ manifest: options.manifest });
const manifest = loader.getManifest();
const diff = diffManifests(result.models, manifest, {
prune: options.prune,
});
if (options.json) {
console.log(JSON.stringify(diff, null, 2));
if (!options.dryRun) {
const updated = applyDiff(manifest, diff, { replace: options.replace });
saveManifest(updated, options.manifest);
}
return;
}
// Print diff summary
console.log(summarizeDiff(diff));
if (options.dryRun) {
console.log("\n(Dry run - no changes applied)");
return;
}
// Apply changes
const hasChanges =
diff.added.length > 0 ||
diff.updated.length > 0 ||
diff.removed.length > 0;
if (!hasChanges) {
console.log("\nNo changes to apply.");
return;
}
const updated = applyDiff(manifest, diff, { replace: options.replace });
// Update lastScan metadata
updated.lastScan = {
rootPath: directory,
timestamp: new Date().toISOString(),
totalModels: Object.keys(updated.models).length,
};
saveManifest(updated, options.manifest);
console.log(`\nManifest updated with ${Object.keys(updated.models).length} models.`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: ${message}`);
process.exit(1);
}
});
// sync - incremental update
program
.command("sync")
.description("Incrementally sync manifest with model directory")
.option("--manifest <path>", "Manifest file path")
.option("--quick", "Skip size verification")
.action(async (options) => {
try {
const loader = new ModelLoader({ manifest: options.manifest });
const manifest = loader.getManifest();
if (!manifest.lastScan?.rootPath) {
console.error("No previous scan found. Run 'model-loader scan <directory>' first.");
process.exit(1);
}
console.log(`Syncing from ${manifest.lastScan.rootPath}...`);
// Re-scan the directory
const result = await scanDirectory({
rootPath: manifest.lastScan.rootPath,
onProgress: (p) => {
process.stdout.write(`\r[${p.phase}] ${p.current} items...`);
},
});
process.stdout.write("\n");
const diff = diffManifests(result.models, manifest, { prune: true });
const hasChanges =
diff.added.length > 0 ||
diff.updated.length > 0 ||
diff.removed.length > 0;
if (!hasChanges) {
console.log("Manifest is up to date.");
return;
}
console.log(summarizeDiff(diff));
const updated = applyDiff(manifest, diff);
updated.lastScan = {
rootPath: manifest.lastScan.rootPath,
timestamp: new Date().toISOString(),
totalModels: Object.keys(updated.models).length,
};
saveManifest(updated, options.manifest);
console.log(`\nManifest synced: ${Object.keys(updated.models).length} models.`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: ${message}`);
process.exit(1);
}
});
program.parse();