using System.Net.Http.Headers; using System.Text.Json; using System.Text.Json.Serialization; namespace LlamaApp.HuggingFace; public class HubClient(string? token) { private static string HUGGINGFACE_HUB_BASE_URL = "https://huggingface.co/api"; public sealed class HubUserInfoClient(string baseUrl, string? token) { /// /// The authenticated user's profile, distilled from the whoami-v2 /// response to just the fields the UI needs. /// public record UserInfo { /// Internal user id (Mongo ObjectId) — NOT routable on the website. public string Id = ""; /// Username — the public profile lives at https://hf.co/<Name>. public string Name = ""; /// Avatar image URL (CDN), empty when the user has none. public string AvatarUrl = ""; }; private string Url { get; } = baseUrl; /// /// GET /whoami-v2 with the configured Bearer token. Returns null when /// no token is provided (no request is made), when the token is /// rejected, or on any network/parse failure — the caller's avatar is /// a best-effort decoration and must never fault the app. /// public async Task WhoAmI(CancellationToken cancel = default) { if (string.IsNullOrWhiteSpace(token)) return null; using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; return await WhoAmI(client, cancel); } // Split from the public overload so tests can drive the HTTP path with // a mock handler (the public overload owns its short-lived client). internal async Task WhoAmI(HttpClient client, CancellationToken cancel = default) { if (string.IsNullOrWhiteSpace(token)) return null; try { using var req = new HttpRequestMessage(HttpMethod.Get, $"{Url}/whoami-v2"); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); using var resp = await client.SendAsync(req, cancel); // A rejected/expired token (401/403) is an expected answer, not // an error — the user simply isn't authenticated. if (!resp.IsSuccessStatusCode) return null; var json = await resp.Content.ReadAsStringAsync(cancel); return Parse(json); } catch (HttpRequestException) { return null; } } /// /// whoami-v2 response DTO — only the fields /// needs; the rest of the (large) payload (auth token details, orgs, /// billing, …) is ignored by the deserializer. /// internal sealed class UserInfoDto { [JsonPropertyName("id")] public string? Id { get; set; } [JsonPropertyName("name")] public string? Name { get; set; } [JsonPropertyName("avatarUrl")] public string? AvatarUrl { get; set; } } /// /// Parses a whoami-v2 JSON payload into . Returns /// null on malformed JSON; missing fields map to empty strings. /// internal static UserInfo? Parse(string json) { try { var dto = JsonSerializer.Deserialize(json); return dto is null ? null : new UserInfo { Id = dto.Id ?? "", Name = dto.Name ?? "", AvatarUrl = dto.AvatarUrl ?? "", }; } catch (JsonException) { return null; } } } public HubUserInfoClient UserInfo { get; } = new (HUGGINGFACE_HUB_BASE_URL, token); }