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; } = "";
}
}