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:
mika kuns
2026-08-05 10:00:25 +02:00
parent 8d7ba1e314
commit 20d17c6887
11 changed files with 619 additions and 0 deletions
@@ -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;
}
@@ -0,0 +1,6 @@
namespace ClaudeDo.Worker.Usage.Interfaces;
public interface IUsageClient
{
Task<UsageSnapshot> GetUsageAsync(CancellationToken ct = default);
}
+18
View File
@@ -0,0 +1,18 @@
namespace ClaudeDo.Worker.Usage;
public sealed record UsageBucket(double Utilization, DateTimeOffset? ResetsAt);
public sealed record UsageLimitRow(
string Kind,
string Group,
double Percent,
string Severity,
DateTimeOffset? ResetsAt,
string? ScopeModelDisplayName,
bool IsActive);
public sealed record UsageSnapshot(
UsageBucket? FiveHour,
UsageBucket? SevenDay,
IReadOnlyList<UsageLimitRow> Limits,
DateTime FetchedAtUtc);
@@ -0,0 +1,68 @@
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Usage.Interfaces;
namespace ClaudeDo.Worker.Usage;
/// <summary>
/// Polls <see cref="IUsageClient"/> on <see cref="WorkerConfig.UsagePollIntervalSeconds"/> and keeps
/// <see cref="UsageState"/> current. Polls once immediately at startup. A failure is logged as a
/// warning at most once per distinct error message, to avoid log spam on a persistent outage.
/// </summary>
public sealed class UsageMonitorService : BackgroundService
{
private readonly IUsageClient _client;
private readonly UsageState _state;
private readonly WorkerConfig _config;
private readonly ILogger<UsageMonitorService> _logger;
private string? _lastLoggedError;
public UsageMonitorService(
IUsageClient client, UsageState state, WorkerConfig config, ILogger<UsageMonitorService> logger)
{
_client = client;
_state = state;
_config = config;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await TickAsync(stoppingToken);
try
{
await Task.Delay(TimeSpan.FromSeconds(_config.UsagePollIntervalSeconds), stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
}
}
internal async Task TickAsync(CancellationToken ct)
{
try
{
var snapshot = await _client.GetUsageAsync(ct);
_state.ReportSuccess(snapshot);
_lastLoggedError = null;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_state.ReportFailure(ex.Message, DateTime.UtcNow);
if (_lastLoggedError != ex.Message)
{
_logger.LogWarning(ex, "UsageMonitorService: failed to fetch usage: {Message}", ex.Message);
_lastLoggedError = ex.Message;
}
}
}
}
+47
View File
@@ -0,0 +1,47 @@
namespace ClaudeDo.Worker.Usage;
/// <summary>
/// Threadsafe holder for the last successful usage snapshot. A failed poll never overwrites
/// a good snapshot — it only records <see cref="LastError"/>, leaving the existing snapshot stale.
/// </summary>
public sealed class UsageState
{
private readonly object _lock = new();
private UsageSnapshot? _snapshot;
private DateTime? _lastAttemptUtc;
private string? _lastError;
public UsageSnapshot? Snapshot
{
get { lock (_lock) return _snapshot; }
}
public DateTime? LastAttemptUtc
{
get { lock (_lock) return _lastAttemptUtc; }
}
public string? LastError
{
get { lock (_lock) return _lastError; }
}
public void ReportSuccess(UsageSnapshot snapshot)
{
lock (_lock)
{
_snapshot = snapshot;
_lastAttemptUtc = snapshot.FetchedAtUtc;
_lastError = null;
}
}
public void ReportFailure(string error, DateTime attemptedAtUtc)
{
lock (_lock)
{
_lastAttemptUtc = attemptedAtUtc;
_lastError = error;
}
}
}