using System.Net.Http.Headers; using System.Text.Json; using System.Text.Json.Nodes; using ClaudeDo.Data; using ClaudeDo.Worker.Usage.Interfaces; namespace ClaudeDo.Worker.Usage; /// /// Fetches the current OAuth usage/limit snapshot from Anthropic using the access token /// Claude Code keeps fresh in ~/.claude/.credentials.json. Never refreshes the token /// itself, and never logs or surfaces the token value. /// public sealed class ClaudeOAuthUsageClient : IUsageClient { private const string UsageUrl = "https://api.anthropic.com/api/oauth/usage"; private readonly HttpClient _http; private readonly string _credentialsPath; public ClaudeOAuthUsageClient(HttpClient http) : this(http, Path.Combine(Paths.Expand("~/.claude"), ".credentials.json")) { } internal ClaudeOAuthUsageClient(HttpClient http, string credentialsPath) { _http = http; _credentialsPath = credentialsPath; } public async Task GetUsageAsync(CancellationToken ct = default) { var token = ReadAccessToken(); using var request = new HttpRequestMessage(HttpMethod.Get, UsageUrl); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); request.Headers.Add("anthropic-beta", "oauth-2025-04-20"); using var response = await _http.SendAsync(request, ct); if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"Usage request failed with status {(int)response.StatusCode}."); var body = await response.Content.ReadAsStringAsync(ct); return Parse(body); } private string ReadAccessToken() { if (!File.Exists(_credentialsPath)) throw new InvalidOperationException($"Claude credentials file not found at '{_credentialsPath}'."); string json; try { json = File.ReadAllText(_credentialsPath); } catch (IOException ex) { throw new InvalidOperationException($"Failed to read Claude credentials file: {ex.Message}", ex); } JsonNode? root; try { root = JsonNode.Parse(json); } catch (JsonException ex) { throw new InvalidOperationException($"Failed to parse Claude credentials file: {ex.Message}", ex); } var token = root?["claudeAiOauth"]?["accessToken"]?.GetValue(); if (string.IsNullOrWhiteSpace(token)) throw new InvalidOperationException("Claude credentials file has no accessToken."); return token; } /// /// Defensive parse of the usage response: missing/null buckets become null, missing /// limits becomes an empty list, and unknown fields are ignored. Only an /// unparsable body throws. /// internal static UsageSnapshot Parse(string json) { JsonNode? root; try { root = JsonNode.Parse(json); } catch (JsonException ex) { throw new InvalidOperationException($"Failed to parse usage response: {ex.Message}", ex); } if (root is not JsonObject obj) throw new InvalidOperationException("Usage response was not a JSON object."); try { var fiveHour = ParseBucket(obj["five_hour"]); var sevenDay = ParseBucket(obj["seven_day"]); var limits = ParseLimits(obj["limits"] as JsonArray); return new UsageSnapshot(fiveHour, sevenDay, limits, DateTime.UtcNow); } catch (InvalidOperationException) { throw; } catch (Exception ex) { throw new InvalidOperationException($"Failed to parse usage response: {ex.Message}", ex); } } private static UsageBucket? ParseBucket(JsonNode? node) { if (node is not JsonObject obj) return null; var utilization = TryGetDouble(obj["utilization"]) ?? 0; var resetsAt = TryGetDateTimeOffset(obj["resets_at"]); return new UsageBucket(utilization, resetsAt); } private static List ParseLimits(JsonArray? array) { var result = new List(); if (array is null) return result; foreach (var item in array) { if (item is not JsonObject row) continue; var kind = TryGetString(row["kind"]) ?? ""; var group = TryGetString(row["group"]) ?? ""; var percent = TryGetDouble(row["percent"]) ?? 0; var severity = TryGetString(row["severity"]) ?? ""; var resetsAt = TryGetDateTimeOffset(row["resets_at"]); var scopeModelDisplayName = TryGetString((row["scope"] as JsonObject)?["model"]?["display_name"]); var isActive = TryGetBool(row["is_active"]) ?? false; result.Add(new UsageLimitRow(kind, group, percent, severity, resetsAt, scopeModelDisplayName, isActive)); } return result; } private static string? TryGetString(JsonNode? node) => node is JsonValue value && value.TryGetValue(out string? s) ? s : null; private static double? TryGetDouble(JsonNode? node) => node is JsonValue value && value.TryGetValue(out double d) ? d : null; private static bool? TryGetBool(JsonNode? node) => node is JsonValue value && value.TryGetValue(out bool b) ? b : null; private static DateTimeOffset? TryGetDateTimeOffset(JsonNode? node) => node is JsonValue value && value.TryGetValue(out string? s) && DateTimeOffset.TryParse(s, out var dto) ? dto : null; }