feat(worker): add OAuth usage client + poller
Adds Usage/ with ClaudeOAuthUsageClient (reads the access token Claude Code keeps fresh at ~/.claude/.credentials.json, calls the Anthropic OAuth usage endpoint, defensively parses buckets/limits), UsageState (threadsafe last-good-snapshot holder that never regresses on failure), and UsageMonitorService (BackgroundService polling on the new usage_poll_interval_seconds config, min 15s, one poll at startup, warns at most once per distinct error).
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the current OAuth usage/limit snapshot from Anthropic using the access token
|
||||
/// Claude Code keeps fresh in <c>~/.claude/.credentials.json</c>. Never refreshes the token
|
||||
/// itself, and never logs or surfaces the token value.
|
||||
/// </summary>
|
||||
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<UsageSnapshot> 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<string>();
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
throw new InvalidOperationException("Claude credentials file has no accessToken.");
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defensive parse of the usage response: missing/null buckets become null, missing
|
||||
/// <c>limits</c> becomes an empty list, and unknown fields are ignored. Only an
|
||||
/// unparsable body throws.
|
||||
/// </summary>
|
||||
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<UsageLimitRow> ParseLimits(JsonArray? array)
|
||||
{
|
||||
var result = new List<UsageLimitRow>();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user