Compare commits

...
2 changed files with 82 additions and 18 deletions
+6 -3
View File
@@ -1,3 +1,6 @@
## What's new in beta.34
- Add: Session Diary section showing 5h burn rate history sparkline
- Add: Extra Usage Credits section showing spent/limit/balance (when enabled)
## What's new in beta.10
### Bug fixes
- Limits now update correctly on each refresh cycle
- Session expiry is detected and prompts re-authentication instead of silently showing stale data
- Plan name (e.g. Pro, Max) is now read from the API instead of being hardcoded
+76 -15
View File
@@ -20,7 +20,7 @@ public class UsageViewModel : INotifyPropertyChanged
private string? _errorMessage;
private bool _isSignedIn;
private string _userEmail = "";
private string _planLabel = "Pro";
private string _planLabel = "Claude";
private DateTime? _lastUpdated;
private OverageSpendLimit? _overage;
private PrepaidCredits? _prepaid;
@@ -100,9 +100,36 @@ public class UsageViewModel : INotifyPropertyChanged
// HttpClient refresh — WebView2 is NOT used here to avoid spawning a heavy
// browser process every refresh cycle (causes memory accumulation).
// SaveAndClose at login time is responsible for fetching live data via WebView2.
var (limits, overage, prepaid, extraUsage, email, orgId, ok) = hasCookies
? await TryHttpRefreshAsync(cookies)
: ([], null, null, null, null, null, false);
List<AgentLimit> limits = [];
OverageSpendLimit? overage = null;
PrepaidCredits? prepaid = null;
ExtraUsage? extraUsage = null;
string? email = null, orgId = null, planLabel = null;
string? refreshError = null;
if (hasCookies)
{
try
{
(limits, overage, prepaid, extraUsage, email, orgId, planLabel, _) =
await TryHttpRefreshAsync(cookies);
}
catch (Exception ex)
{
refreshError = ex.Message;
bool authFailed = ex.Message.Contains("re-authenticate");
if (authFailed)
{
await Application.Current.Dispatcher.InvokeAsync(() =>
{
IsSignedIn = false;
ErrorMessage = ex.Message;
IsLoading = false;
});
return;
}
}
}
// Fill in any blanks from cached values saved at login time
if (string.IsNullOrEmpty(email)) email = AppSettings.Default.Email;
@@ -132,9 +159,10 @@ public class UsageViewModel : INotifyPropertyChanged
await Application.Current.Dispatcher.InvokeAsync(() =>
{
if (limits.Count > 0) { Limits = limits; Overage = overage; Prepaid = prepaid; ExtraUsage = extraUsage; }
if (!string.IsNullOrEmpty(planLabel)) PlanLabel = planLabel;
if (!string.IsNullOrEmpty(email)) UserEmail = email;
IsSignedIn = true;
ErrorMessage = null;
ErrorMessage = refreshError;
LastUpdated = DateTime.Now;
IsLoading = false;
});
@@ -149,17 +177,19 @@ public class UsageViewModel : INotifyPropertyChanged
}
}
private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, ExtraUsage?, string?, string?, bool)>
private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, ExtraUsage?, string?, string?, string?, bool)>
TryHttpRefreshAsync(List<(string Name, string Value, string Domain, string Path)> cookies)
{
try
{
using var http = BuildClient(cookies);
var bootstrapResp = await http.GetAsync("https://claude.ai/api/bootstrap");
if ((int)bootstrapResp.StatusCode is 401 or 403)
throw new Exception("Session expired — please re-authenticate.");
if (!bootstrapResp.IsSuccessStatusCode)
return ([], null, null, null, null, false);
throw new Exception($"Bootstrap failed ({(int)bootstrapResp.StatusCode}).");
var (email, orgId) = ParseBootstrap(await bootstrapResp.Content.ReadAsStringAsync());
var (email, orgId, planLabel) = ParseBootstrap(await bootstrapResp.Content.ReadAsStringAsync());
if (string.IsNullOrEmpty(orgId))
orgId = await FetchOrgIdFromListAsync(http);
@@ -167,19 +197,31 @@ public class UsageViewModel : INotifyPropertyChanged
orgId = AppSettings.Default.OrgId;
if (string.IsNullOrEmpty(orgId))
return ([], null, null, null, email, orgId, true);
return ([], null, null, null, email, orgId, planLabel, true);
AppSettings.Default.OrgId = orgId;
AppSettings.Default.Save();
var ut = FetchAsync<UsageResponse>(http, $"https://claude.ai/api/organizations/{orgId}/usage");
var usageUrl = $"https://claude.ai/api/organizations/{orgId}/usage";
var ut = http.GetAsync(usageUrl);
var ot = FetchAsync<OverageSpendLimit>(http, $"https://claude.ai/api/organizations/{orgId}/overage_spend_limit");
var pt = FetchAsync<PrepaidCredits>(http, $"https://claude.ai/api/organizations/{orgId}/prepaid/credits");
await Task.WhenAll(ut, ot, pt);
return (BuildLimits(ut.Result), ot.Result, pt.Result, ut.Result?.ExtraUsage, email, orgId, true);
var usageResp = ut.Result;
if (!usageResp.IsSuccessStatusCode)
throw new Exception($"Usage fetch failed ({(int)usageResp.StatusCode}).");
var usageJson = await usageResp.Content.ReadAsStringAsync();
var usage = JsonSerializer.Deserialize<UsageResponse>(usageJson, JsonOpts);
// Persist fresh usage so cache reflects live data
AppSettings.Default.UsageJson = usageJson;
AppSettings.Default.Save();
return (BuildLimits(usage), ot.Result, pt.Result, usage?.ExtraUsage, email, orgId, planLabel, true);
}
catch { return ([], null, null, null, null, null, false); }
catch { throw; }
}
private static async Task<(List<AgentLimit>, string?, string?)> TryWebView2RefreshAsync()
@@ -351,7 +393,7 @@ public class UsageViewModel : INotifyPropertyChanged
}
// Parse email and org ID out of bootstrap JSON (multiple fallback paths for org ID)
private static (string? Email, string? OrgId) ParseBootstrap(string json)
private static (string? Email, string? OrgId, string? PlanLabel) ParseBootstrap(string json)
{
try
{
@@ -393,9 +435,28 @@ public class UsageViewModel : INotifyPropertyChanged
orgId = uuid.GetString();
}
return (email, orgId);
string? planLabel = null;
if (root.TryGetProperty("account", out var acctPlan) &&
acctPlan.TryGetProperty("memberships", out var plMems) && plMems.GetArrayLength() > 0 &&
plMems[0].TryGetProperty("organization", out var plOrg) &&
plOrg.TryGetProperty("capabilities", out var caps) &&
caps.ValueKind == System.Text.Json.JsonValueKind.Array)
{
foreach (var cap in caps.EnumerateArray())
{
var s = cap.GetString() ?? "";
if (s.StartsWith("claude_", StringComparison.OrdinalIgnoreCase))
{
var name = s.Substring("claude_".Length);
if (name.Length > 0)
planLabel = char.ToUpper(name[0]) + name.Substring(1).ToLower();
break;
}
}
}
return (email, orgId, planLabel);
}
catch { return (null, null); }
catch { return (null, null, null); }
}
private static async Task<string?> FetchOrgIdFromListAsync(HttpClient http)