From 2777ace17902c3317d273a81591e9651d3da19fa Mon Sep 17 00:00:00 2001 From: mabyes1PG Date: Sat, 25 Jul 2026 22:02:38 +0800 Subject: [PATCH] fix: refresh Codex quota without running Codex --- .../Quotas/AiQuotaService.cs | 4 +- .../Quotas/CodexQuotaReader.cs | 149 ++++++++++++++++++ .../AiQuotaServiceTests.cs | 17 ++ 3 files changed, 169 insertions(+), 1 deletion(-) diff --git a/src/PhoneMonitor.Host/Quotas/AiQuotaService.cs b/src/PhoneMonitor.Host/Quotas/AiQuotaService.cs index a3bc281..c991575 100644 --- a/src/PhoneMonitor.Host/Quotas/AiQuotaService.cs +++ b/src/PhoneMonitor.Host/Quotas/AiQuotaService.cs @@ -29,7 +29,9 @@ private async Task BuildSnapshotAsync(bool forceAgyRefresh, Cance Providers = new List() }; - snapshot.Providers.AddRange(CodexQuotaReader.ReadCodexQuotas()); + snapshot.Providers.AddRange(forceAgyRefresh + ? await CodexQuotaReader.RefreshCodexQuotasAsync(cancellationToken) + : CodexQuotaReader.ReadCodexQuotas()); snapshot.Providers.AddRange(await agy.ReadAgyQuotasAsync(forceAgyRefresh, cancellationToken)); return snapshot; } diff --git a/src/PhoneMonitor.Host/Quotas/CodexQuotaReader.cs b/src/PhoneMonitor.Host/Quotas/CodexQuotaReader.cs index ca5d6e1..ad5c72a 100644 --- a/src/PhoneMonitor.Host/Quotas/CodexQuotaReader.cs +++ b/src/PhoneMonitor.Host/Quotas/CodexQuotaReader.cs @@ -2,8 +2,12 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; using System.Text; using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; using static PhoneMonitor.Host.Quotas.QuotaJsonHelpers; using static PhoneMonitor.Host.Quotas.QuotaPaths; using static PhoneMonitor.Host.Quotas.QuotaShared; @@ -19,8 +23,23 @@ internal static class CodexQuotaReader private const int MaxSessionFiles = 120; private const int TailBytes = 768 * 1024; private static readonly object CacheFileLock = new object(); + private static readonly HttpClient UsageClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(15) + }; internal static IEnumerable ReadCodexQuotas() + { + return ReadCodexQuotas(null); + } + + internal static async Task> RefreshCodexQuotasAsync(CancellationToken cancellationToken) + { + var refreshed = await TryFetchCodexQuotaAsync(cancellationToken); + return ReadCodexQuotas(refreshed); + } + + private static IEnumerable ReadCodexQuotas(AiQuotaStatus refreshedQuota) { var codexHomes = ResolveCodexHomes().ToList(); var cacheDirectory = CodexQuotaCacheDirectory(); @@ -48,6 +67,10 @@ internal static IEnumerable ReadCodexQuotas() var activeQuota = !string.IsNullOrWhiteSpace(activeHome) ? ReadCodexQuota(activeHome) : Unavailable("codex", "Codex", "Codex session directory was not found.", activeHome); + if (IsUsableQuota(refreshedQuota)) + { + activeQuota = refreshedQuota; + } if (!IsUsableQuota(activeQuota)) { activeQuota = codexHomes @@ -96,6 +119,100 @@ internal static IEnumerable ReadCodexQuotas() return new[] { activeQuota }; } + private static async Task TryFetchCodexQuotaAsync(CancellationToken cancellationToken) + { + var authFile = ResolveCodexHomes() + .Select(home => Path.Combine(home, "auth.json")) + .FirstOrDefault(File.Exists); + if (string.IsNullOrWhiteSpace(authFile)) + { + return null; + } + + try + { + using var authDocument = JsonDocument.Parse(File.ReadAllText(authFile)); + if (!TryGetProperty(authDocument.RootElement, "tokens", out var tokens) || + tokens.ValueKind != JsonValueKind.Object) + { + return null; + } + + var accessToken = TryGetString(tokens, "access_token"); + if (string.IsNullOrWhiteSpace(accessToken)) + { + return null; + } + + using var request = new HttpRequestMessage( + HttpMethod.Get, + "https://chatgpt.com/backend-api/wham/usage"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + request.Headers.UserAgent.ParseAdd("VibeDeck/1.0"); + var accountId = TryGetString(tokens, "account_id"); + if (!string.IsNullOrWhiteSpace(accountId)) + { + request.Headers.TryAddWithoutValidation("ChatGPT-Account-ID", accountId); + } + + using var response = await UsageClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return null; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + return TryReadCodexQuotaFromUsageResponse(json); + } + catch (Exception ex) when ( + ex is IOException || + ex is UnauthorizedAccessException || + ex is JsonException || + ex is HttpRequestException || + ex is TaskCanceledException || + ex is FormatException) + { + return null; + } + } + + internal static AiQuotaStatus TryReadCodexQuotaFromUsageResponse(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + if (!TryGetProperty(root, "rate_limit", out var limits) || + limits.ValueKind != JsonValueKind.Object) + { + return null; + } + + var credits = TryGetProperty(root, "credits", out var creditInfo) && + creditInfo.ValueKind == JsonValueKind.Object + ? creditInfo + : default; + return new AiQuotaStatus + { + Id = "codex", + Label = "Codex", + Family = "codex", + AccountId = "local", + State = "ok", + Source = "https://chatgpt.com/backend-api/wham/usage", + Detail = "Latest Codex quota fetched directly by VibeDeck.", + ObservedAt = DateTimeOffset.UtcNow, + AccountTier = TryGetString(root, "plan_type"), + CreditBalance = credits.ValueKind == JsonValueKind.Object ? TryGetDouble(credits, "balance") : null, + CreditUnlimited = credits.ValueKind == JsonValueKind.Object ? TryGetBoolean(credits, "unlimited") : null, + Primary = ReadUsageWindow(limits, "primary_window", "5h"), + Secondary = ReadUsageWindow(limits, "secondary_window", "Weekly") + }; + } + private static IReadOnlyList ResolveCodexHomes() { var homes = new List(); @@ -336,6 +453,20 @@ private static void WriteCodexQuotaCache(string cacheDirectory, AiQuotaStatus st Directory.CreateDirectory(cacheDirectory); var accountKey = FirstNonEmpty(status.AccountId, status.AccountEmail, status.Id, "local"); var path = Path.Combine(cacheDirectory, $"{SafeFileName(accountKey)}.json"); + if (File.Exists(path)) + { + try + { + var cached = JsonSerializer.Deserialize(File.ReadAllText(path), CacheJsonOptions); + if (cached?.ObservedAt > status.ObservedAt) + { + return; + } + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is JsonException) + { + } + } File.WriteAllText(path, JsonSerializer.Serialize(status, CacheJsonOptions)); } } @@ -505,6 +636,24 @@ private static QuotaWindow ReadWindow(JsonElement limits, string propertyName, s }; } + private static QuotaWindow ReadUsageWindow(JsonElement limits, string propertyName, string label) + { + if (!TryGetProperty(limits, propertyName, out var window) || + window.ValueKind != JsonValueKind.Object) + { + return null; + } + + var windowSeconds = TryGetInt(window, "limit_window_seconds"); + return new QuotaWindow + { + Label = label, + UsedPercent = TryGetDouble(window, "used_percent"), + WindowMinutes = windowSeconds.HasValue ? windowSeconds.Value / 60 : (int?)null, + ResetsAt = TryGetUnixTime(window, "reset_at") + }; + } + internal sealed class CodexAccountIdentity { public string AccountId { get; set; } diff --git a/tests/PhoneMonitor.Host.Tests/AiQuotaServiceTests.cs b/tests/PhoneMonitor.Host.Tests/AiQuotaServiceTests.cs index 7da71fc..74aef22 100644 --- a/tests/PhoneMonitor.Host.Tests/AiQuotaServiceTests.cs +++ b/tests/PhoneMonitor.Host.Tests/AiQuotaServiceTests.cs @@ -31,6 +31,23 @@ public void ReadsCodexCreditBalanceFromLatestRateLimitEvent() } } + [Fact] + public void ReadsCodexQuotaFromLiveUsageResponse() + { + const string response = "{\"plan_type\":\"plus\",\"rate_limit\":{\"primary_window\":{\"used_percent\":23,\"limit_window_seconds\":18000,\"reset_at\":1780000000},\"secondary_window\":{\"used_percent\":41,\"limit_window_seconds\":604800,\"reset_at\":1780500000}},\"credits\":{\"balance\":\"12.5\",\"unlimited\":false}}"; + + var status = CodexQuotaReader.TryReadCodexQuotaFromUsageResponse(response); + + Assert.Equal("ok", status.State); + Assert.Equal("plus", status.AccountTier); + Assert.Equal(23d, status.Primary.UsedPercent); + Assert.Equal(300, status.Primary.WindowMinutes); + Assert.Equal(41d, status.Secondary.UsedPercent); + Assert.Equal(10080, status.Secondary.WindowMinutes); + Assert.Equal(12.5d, status.CreditBalance); + Assert.False(status.CreditUnlimited); + } + [Fact] public void AtomicallyReplacesCodexAuthFileFromSavedProfile() {