using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; using LlamaApp.Common; namespace LlamaApp.HuggingFace; /// /// Fetches and parses the remote model catalog from /// https://llama.app/v1/catalog.json, flattening the family → size → /// build hierarchy into a flat set of objects — one /// per downloadable build (quant). Also implements /// so it can plug into the generic catalog layer. /// public sealed class Catalog : IModelSource { /// Remote catalog endpoint. private static readonly Uri CatalogUrl = new("https://llama.app/v1/catalog.json"); private static readonly JsonSerializerOptions JsonOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; /// /// Fetches the catalog and returns one per build /// (quant) across all families and sizes. /// public static async Task> FetchAsync(CancellationToken cancel = default) { using var client = new HttpClient(); client.DefaultRequestHeaders.UserAgent.ParseAdd("LlamaApp/1.0"); client.Timeout = TimeSpan.FromSeconds(15); using var response = await client.GetAsync(CatalogUrl, cancel); response.EnsureSuccessStatusCode(); var stream = await response.Content.ReadAsStreamAsync(cancel); var families = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancel); if (families is null || families.Length == 0) return []; return Flatten(families); } /// /// Flattens the nested family → size → build structure into a flat list of /// records, one per build (quant). Internal for /// unit tests. /// internal static List Flatten(CatalogFamily[] families) { var repos = new List(families.Length * 4); repos.AddRange( from family in families from size in family.Sizes from build in size.Builds select new Repository { Name = build.Repo, Description = family.Description, License = family.License, Parameters = size.Params, Size = build.Size, Vision = size.Vision, DisplayName = size.Name, Brand = family.Brand, Quant = build.Quant, SizeBytes = build.SizeBytes, Featured = family.Featured, } ); return repos; } // ---- IModelSource ---- /// Returns all catalog (remote) models as . public async Task> GetModelsAsync() where T : IModel { var repos = await FetchAsync(); return repos.Cast().ToList(); } /// /// Returns local (already-downloaded) models by scanning the HF cache. /// public async Task> GetLocalModelsAsync() where T : IModel { // The cache directory defaults to the standard HF hub layout; callers // can override it via Settings. Here we use the default — MainWindow // passes the user-configured path to FetchLocalAsync directly. var repos = await FetchLocalAsync( Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "huggingface", "hub") ); return repos.Cast().ToList(); } /// /// Scans the local Hugging Face cache (see Settings.CacheDirectory) /// for downloaded GGUF models and returns one per /// found build. Each entry is enriched with catalog metadata (display name, /// params, license, …) when its repo id matches a remote catalog entry; /// otherwise it's surfaced with basic info derived from the repo id and the /// on-disk file size. /// private static async Task> FetchLocalAsync(string cacheDirectory, CancellationToken cancel = default) { if (!Directory.Exists(cacheDirectory)) return []; // Build a repo-id → catalog-entry lookup so local models inherit rich // metadata (display name, params, license, …) when available. Failures // here (offline, parse error) just mean we fall back to cache-derived info. Dictionary? catalogLookup = null; try { var remote = await FetchAsync(cancel); // The catalog is flattened per-quant, so a repo can appear more than // once — GroupBy keeps a single entry (ToDictionary would throw on // the duplicate keys and silently skip enrichment). catalogLookup = remote .GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); } catch (Exception ex) { // Offline or parse error — proceed with cache-only info. Log.Warn(ex, "catalog fetch for local enrichment failed"); } var results = new List(); foreach (var modelDir in Directory.EnumerateDirectories(cacheDirectory, "models--*")) { cancel.ThrowIfCancellationRequested(); var dirName = Path.GetFileName(modelDir); // "models--{org}--{repo}" → "{org}/{repo}" var repoId = dirName["models--".Length..].Replace("--", "/"); var snapshotsDir = Path.Combine(modelDir, "snapshots"); if (!Directory.Exists(snapshotsDir)) continue; // Each snapshot is a commit hash; pick the first that has GGUF files. foreach (var snapshotDir in Directory.EnumerateDirectories(snapshotsDir)) { cancel.ThrowIfCancellationRequested(); foreach (var ggufFile in Directory.EnumerateFiles(snapshotDir, "*.gguf")) { cancel.ThrowIfCancellationRequested(); var sizeBytes = TryGetFileSize(ggufFile); var fileName = Path.GetFileNameWithoutExtension(ggufFile); // Match against the remote catalog by repo id for metadata. if (catalogLookup != null && catalogLookup.TryGetValue(repoId, out var matched)) { results.Add(new Repository { Name = matched.Name, Description = matched.Description, License = matched.License, Parameters = matched.Parameters, // Prefer the actual on-disk size over the catalog's // (which is the download size, not necessarily what landed). Size = sizeBytes.HasValue ? FormatBytes(sizeBytes.Value) : matched.Size, Vision = matched.Vision, DisplayName = matched.DisplayName, Brand = matched.Brand, Quant = matched.Quant, SizeBytes = sizeBytes ?? matched.SizeBytes, Featured = matched.Featured, }); } else { // Not in the catalog — surface with basic info. results.Add(new Repository { Name = repoId, Description = "", License = "Unknown", Parameters = "", Size = sizeBytes.HasValue ? FormatBytes(sizeBytes.Value) : "", Vision = false, DisplayName = fileName, Brand = repoId.Split('/', StringSplitOptions.None).FirstOrDefault(), Quant = ExtractQuant(fileName), SizeBytes = sizeBytes ?? 0, }); } } } } return results; } /// /// Returns the size of a file, following symlinks/hardlinks to the actual /// blob. Returns null if the file can't be read (e.g. it's a broken symlink /// to a not-yet-downloaded blob). /// private static ulong? TryGetFileSize(string path) { try { var info = new FileInfo(path); // On Windows, HF cache snapshot files can be symlinks to blobs; // resolve the link target to get the real size. If the link is // broken (download incomplete), Length throws — return null. return info.LinkTarget != null ? (ulong?)new FileInfo(info.LinkTarget).Length : (ulong)info.Length; } catch { return null; } } /// Extracts a quant label from a GGUF filename, e.g. "model-Q4_K_M.gguf" → "Q4_K_M". Internal for unit tests. internal static string? ExtractQuant(string fileName) { var parts = fileName.Split('-'); return parts.Select(p => p.Trim()).FirstOrDefault( trimmed => trimmed.Length > 1 && (trimmed[0] == 'Q' || trimmed.StartsWith("mxfp", StringComparison.OrdinalIgnoreCase)) ); } /// /// Formats a byte count as a human-readable size string, e.g. 2526080992 → "2.5 GB". /// Internal for unit tests. /// /// /// Decimal units (1 GB = 1e9 B), whole KB/MB and one fractional digit at GB/TB. /// This mirrors the pre-formatted size strings in catalog.json — and must: /// substitutes this value into the very same /// field the catalog fills, so a different base /// would make an installed model's size differ from its Discover listing /// (12109566560 B reads "12.1 GB" decimal but "11.3 GB" binary). Invariant /// culture for the same reason: the catalog strings are period-separated. /// internal static string FormatBytes(ulong bytes) { if (bytes >= 1_000_000_000_000) return string.Create(CultureInfo.InvariantCulture, $"{bytes / 1_000_000_000_000.0:0.#} TB"); if (bytes >= 1_000_000_000) return string.Create(CultureInfo.InvariantCulture, $"{bytes / 1_000_000_000.0:0.#} GB"); if (bytes >= 1_000_000) return string.Create(CultureInfo.InvariantCulture, $"{bytes / 1_000_000.0:0} MB"); if (bytes >= 1_000) return string.Create(CultureInfo.InvariantCulture, $"{bytes / 1_000.0:0} KB"); return string.Create(CultureInfo.InvariantCulture, $"{bytes} B"); } // ---- JSON DTOs matching the catalog.json schema ---- internal sealed class CatalogFamily { [JsonPropertyName("name")] public string Name { get; set; } = ""; [JsonPropertyName("brand")] public string Brand { get; set; } = ""; [JsonPropertyName("description")] public string Description { get; set; } = ""; [JsonPropertyName("details")] public string Details { get; set; } = ""; [JsonPropertyName("released")] public string Released { get; set; } = ""; [JsonPropertyName("license")] public string License { get; set; } = ""; [JsonPropertyName("featured")] public bool Featured { get; set; } [JsonPropertyName("sizes")] public CatalogSize[] Sizes { get; set; } = []; } internal sealed class CatalogSize { [JsonPropertyName("name")] public string Name { get; set; } = ""; [JsonPropertyName("params")] public string Params { get; set; } = ""; [JsonPropertyName("vision")] public bool Vision { get; set; } [JsonPropertyName("builds")] public CatalogBuild[] Builds { get; set; } = []; } internal sealed class CatalogBuild { [JsonPropertyName("quant")] public string Quant { get; set; } = ""; [JsonPropertyName("size")] public string Size { get; set; } = ""; [JsonPropertyName("sizeBytes")] public ulong SizeBytes { get; set; } [JsonPropertyName("repo")] public string Repo { get; set; } = ""; } }