114 lines
4.3 KiB
C#
114 lines
4.3 KiB
C#
|
|
using System.IO;
|
||
|
|
using System.Net.Http;
|
||
|
|
using System.Text.Json;
|
||
|
|
using ClaudeOverview.Models;
|
||
|
|
|
||
|
|
namespace ClaudeOverview.Services;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Fetches subscription usage (session / weekly limits) from the same endpoint Claude
|
||
|
|
/// Code's /usage command uses, authenticating with the OAuth token stored in
|
||
|
|
/// ~/.claude/.credentials.json (read fresh each call so it tracks token refreshes).
|
||
|
|
/// </summary>
|
||
|
|
public sealed class UsageService
|
||
|
|
{
|
||
|
|
private const string Endpoint = "https://api.anthropic.com/api/oauth/usage";
|
||
|
|
|
||
|
|
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(12) };
|
||
|
|
|
||
|
|
private readonly AppConfig _config;
|
||
|
|
|
||
|
|
public UsageService(AppConfig config) => _config = config;
|
||
|
|
|
||
|
|
public async Task<UsageInfo> FetchAsync()
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
var token = ReadAccessToken(out var tokenError);
|
||
|
|
if (token is null) return new UsageInfo { Error = tokenError };
|
||
|
|
|
||
|
|
using var req = new HttpRequestMessage(HttpMethod.Get, Endpoint);
|
||
|
|
req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + token);
|
||
|
|
req.Headers.TryAddWithoutValidation("anthropic-beta", "oauth-2025-04-20");
|
||
|
|
|
||
|
|
using var resp = await Http.SendAsync(req);
|
||
|
|
if (!resp.IsSuccessStatusCode)
|
||
|
|
{
|
||
|
|
if (resp.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||
|
|
{
|
||
|
|
return new UsageInfo
|
||
|
|
{
|
||
|
|
Error = "rate limited (429 — backing off)",
|
||
|
|
RateLimited = true,
|
||
|
|
RetryAfter = resp.Headers.RetryAfter?.Delta
|
||
|
|
?? (resp.Headers.RetryAfter?.Date is { } retryAt
|
||
|
|
? retryAt.ToUniversalTime() - DateTimeOffset.UtcNow
|
||
|
|
: null),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
var hint = resp.StatusCode == System.Net.HttpStatusCode.Unauthorized
|
||
|
|
? " (token expired — open Claude Code to refresh)"
|
||
|
|
: "";
|
||
|
|
return new UsageInfo { Error = $"usage HTTP {(int)resp.StatusCode}{hint}" };
|
||
|
|
}
|
||
|
|
|
||
|
|
await using var stream = await resp.Content.ReadAsStreamAsync();
|
||
|
|
using var doc = await JsonDocument.ParseAsync(stream);
|
||
|
|
var root = doc.RootElement;
|
||
|
|
|
||
|
|
var info = new UsageInfo();
|
||
|
|
if (root.TryGetProperty("five_hour", out var fh) && fh.ValueKind == JsonValueKind.Object)
|
||
|
|
{
|
||
|
|
info.SessionPercent = GetDouble(fh, "utilization");
|
||
|
|
info.SessionResetsAt = GetDate(fh, "resets_at");
|
||
|
|
}
|
||
|
|
if (root.TryGetProperty("seven_day", out var sd) && sd.ValueKind == JsonValueKind.Object)
|
||
|
|
{
|
||
|
|
info.WeeklyPercent = GetDouble(sd, "utilization");
|
||
|
|
info.WeeklyResetsAt = GetDate(sd, "resets_at");
|
||
|
|
}
|
||
|
|
return info;
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
return new UsageInfo { Error = ex.Message };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private string? ReadAccessToken(out string? error)
|
||
|
|
{
|
||
|
|
error = null;
|
||
|
|
var path = _config.CredentialsPath;
|
||
|
|
if (!File.Exists(path)) { error = "not signed in (no credentials)"; return null; }
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
using var fs = File.OpenRead(path);
|
||
|
|
using var doc = JsonDocument.Parse(fs);
|
||
|
|
if (!doc.RootElement.TryGetProperty("claudeAiOauth", out var oauth))
|
||
|
|
{
|
||
|
|
error = "no OAuth session";
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
var token = oauth.TryGetProperty("accessToken", out var t) ? t.GetString() : null;
|
||
|
|
if (string.IsNullOrEmpty(token)) { error = "no access token"; return null; }
|
||
|
|
return token;
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
error = $"credentials read failed: {ex.Message}";
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static double GetDouble(JsonElement e, string name) =>
|
||
|
|
e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.Number ? p.GetDouble() : 0;
|
||
|
|
|
||
|
|
private static DateTime? GetDate(JsonElement e, string name) =>
|
||
|
|
e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String &&
|
||
|
|
DateTime.TryParse(p.GetString(), null,
|
||
|
|
System.Globalization.DateTimeStyles.AdjustToUniversal, out var dt)
|
||
|
|
? dt : null;
|
||
|
|
}
|