From b72b734e2989253d1c4e1c40280405a9ef478d6e Mon Sep 17 00:00:00 2001 From: nowrap Date: Wed, 19 Aug 2026 08:57:53 +0200 Subject: [PATCH 1/3] codex: name the model a per-model limit belongs to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plan with a per-model quota reports two weekly windows — the account's and the model's — and both rendered as "Codex · Week". The model's own limit sits at 0 % until that model runs, so the pair read as one limit that had stopped filling. The bucket's limitName is the name for it; the label follows the shape Claude's per-model window already uses ("Week · "), without the service prefix because "Codex · Week · GPT-5.3-Codex-Spark" measures 248 px against a 222 px column and the ellipsis would eat the one part that tells the rows apart. Only the account-wide window keeps the active dot. Whether a model is the one currently running is not something the server says, and a dot on both windows costs the one that is true its meaning. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 8 ++++ src/RateTray/Localization/de.json | 1 + src/RateTray/Localization/en.json | 1 + src/RateTray/Providers/CodexUsageProvider.cs | 25 +++++++++-- .../RateTray.Tests/CodexUsageProviderTests.cs | 43 ++++++++++++++++++- 5 files changed, 73 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e65e3c9..5dd170e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- A Codex plan with a per-model quota says which model each limit belongs to. Two weekly windows + both read "Codex · Week", so the model's own limit — sitting at 0 % until that model runs — + looked like the account's limit had stopped filling. The model's window is now labelled after + it, the way Claude's per-model window already was, and only the account-wide window keeps the + "active" dot: whether a model is the one currently running is not something the server says. + ## [0.3.1] - 2026-08-18 A patch release for three things that were wrong in plain sight: a limit that was full without diff --git a/src/RateTray/Localization/de.json b/src/RateTray/Localization/de.json index e4cc3ae..e975156 100644 --- a/src/RateTray/Localization/de.json +++ b/src/RateTray/Localization/de.json @@ -107,6 +107,7 @@ "label.claude.extraUsage": "Extra-Guthaben", "note.claude.extraCurrency": "Limit in {0}", "label.codex.window": "Codex · {0}", + "label.codex.windowNamed": "{0} · {1}", "note.endpoint": "Endpunkt: {0}", "error.claude.noCredentials": "Keine Claude-Anmeldung gefunden (.credentials.json fehlt)", diff --git a/src/RateTray/Localization/en.json b/src/RateTray/Localization/en.json index 9d9b1c8..e40fb07 100644 --- a/src/RateTray/Localization/en.json +++ b/src/RateTray/Localization/en.json @@ -107,6 +107,7 @@ "label.claude.extraUsage": "Extra credit", "note.claude.extraCurrency": "Limit in {0}", "label.codex.window": "Codex · {0}", + "label.codex.windowNamed": "{0} · {1}", "note.endpoint": "Endpoint: {0}", "error.claude.noCredentials": "No Claude sign-in found (.credentials.json missing)", diff --git a/src/RateTray/Providers/CodexUsageProvider.cs b/src/RateTray/Providers/CodexUsageProvider.cs index 3bd5452..4d3dac1 100644 --- a/src/RateTray/Providers/CodexUsageProvider.cs +++ b/src/RateTray/Providers/CodexUsageProvider.cs @@ -170,8 +170,17 @@ internal static List Parse(JsonObject result, out string? planType .ToList(); } + /// + /// Turns one bucket's windows into readings. Every bucket but the account-wide one belongs + /// to a single model and names it in limitName; without that name a plan with a + /// per-model quota shows two rows both reading "Codex · Week", and the one counting a model + /// that has not been used yet looks like the same limit refusing to move. + /// private static void AddWindows(List readings, JsonObject bucket, string prefix, string? planType) { + var accountWide = prefix == "codex"; + var limitName = bucket["limitName"]?.GetValue(); + foreach (var slot in (string[])["primary", "secondary"]) { if (bucket[slot] is not JsonObject window) continue; @@ -180,17 +189,27 @@ private static void AddWindows(List readings, JsonObject bucket, s var minutes = window["windowDurationMins"]?.GetValue(); var span = minutes is > 0 ? TimeSpan.FromMinutes(minutes.Value) : (TimeSpan?)null; var resets = window["resetsAt"]?.GetValue(); + var windowText = span is { } s ? LimitReading.FormatWindow(s) : slot; readings.Add(new LimitReading { - Id = prefix == "codex" ? $"codex.{slot}" : $"{prefix}.{slot}", - Label = Loc.T("label.codex.window", span is { } s ? LimitReading.FormatWindow(s) : slot), + Id = accountWide ? $"codex.{slot}" : $"{prefix}.{slot}", + // "Week · ", the shape Claude's per-model window already uses, and + // without the service prefix on purpose: "Codex · Week · GPT-5.3-Codex-Spark" + // is wider than the label column, so the ellipsis would eat the model name — + // the only part that tells the two weekly rows apart. + Label = limitName is { Length: > 0 } name + ? Loc.T("label.codex.windowNamed", windowText, name) + : Loc.T("label.codex.window", windowText), Group = "Codex", Percent = percentNode.GetValue(), ResetsAt = resets is > 0 ? DateTimeOffset.FromUnixTimeSeconds(resets.Value) : null, Window = span, Note = planType, - IsActive = slot == "primary", + // Only the account-wide window is the one being spent right now: a model's own + // limit moves solely while that model runs, and the server does not say whether + // it is. Marking both as active made the two indistinguishable rows worse. + IsActive = slot == "primary" && accountWide, }); } } diff --git a/tests/RateTray.Tests/CodexUsageProviderTests.cs b/tests/RateTray.Tests/CodexUsageProviderTests.cs index b35998d..a659006 100644 --- a/tests/RateTray.Tests/CodexUsageProviderTests.cs +++ b/tests/RateTray.Tests/CodexUsageProviderTests.cs @@ -11,13 +11,15 @@ public class CodexUsageProviderTests { "rateLimits": { "limitId": "codex", + "limitName": null, "primary": { "usedPercent": 6, "windowDurationMins": 10080, "resetsAt": 1786468203 }, "secondary": { "usedPercent": 41, "windowDurationMins": 300, "resetsAt": 1786400000 }, "planType": "plus" }, "rateLimitsByLimitId": { - "codex": { "limitId": "codex", "primary": { "usedPercent": 6, "windowDurationMins": 10080, "resetsAt": 1786468203 }, "planType": "plus" }, - "codex-max": { "limitId": "codex-max", "primary": { "usedPercent": 3, "windowDurationMins": 1440, "resetsAt": 1786468203 }, "planType": "pro" } + "codex": { "limitId": "codex", "limitName": null, "primary": { "usedPercent": 6, "windowDurationMins": 10080, "resetsAt": 1786468203 }, "planType": "plus" }, + "codex-max": { "limitId": "codex-max", "primary": { "usedPercent": 3, "windowDurationMins": 1440, "resetsAt": 1786468203 }, "planType": "pro" }, + "codex_spark": { "limitId": "codex_spark", "limitName": "GPT-5.3-Codex-Spark", "primary": { "usedPercent": 0, "windowDurationMins": 10080, "resetsAt": 1786500000 }, "planType": "plus" } } } """; @@ -63,6 +65,43 @@ public void Parse_adds_extra_buckets_without_duplicating_the_default_one() Assert.Contains(readings, r => r.Id == "codex.codex_max.primary" && r.Percent == 3); } + /// + /// A plan with a per-model quota reports two weekly windows: the account's and the model's. + /// Both read "Codex · Week" until the model is named, and the model's — untouched at 0 % + /// until that model runs — then looks like a bar that stopped filling. + /// + [Fact] + public void Parse_names_a_per_model_bucket_after_the_model() + { + var readings = CodexUsageProvider.Parse(Json(Payload), out _); + + var account = readings.Single(r => r.Id == "codex.primary"); + var model = readings.Single(r => r.Id == "codex.codex_spark.primary"); + + Assert.Equal("Codex · Week", account.Label); + Assert.Equal("Week · GPT-5.3-Codex-Spark", model.Label); + } + + [Fact] + public void Parse_falls_back_to_the_window_where_a_bucket_is_unnamed() + { + var readings = CodexUsageProvider.Parse(Json(Payload), out _); + + Assert.Equal("Codex · 1 d", readings.Single(r => r.Id == "codex.codex_max.primary").Label); + } + + [Fact] + public void Parse_marks_only_the_account_wide_window_as_active() + { + // A model's own limit moves solely while that model runs, and the server does not say + // whether it is. A dot on both windows claims something we do not know and costs the + // one that is true its meaning. + var readings = CodexUsageProvider.Parse(Json(Payload), out _); + + Assert.True(readings.Single(r => r.Id == "codex.primary").IsActive); + Assert.False(readings.Single(r => r.Id == "codex.codex_spark.primary").IsActive); + } + [Fact] public void Parse_skips_a_window_without_a_used_percentage() { From f55d7c8d613ec19d9f8082f17cb76aaa9fee23cd Mon Sep 17 00:00:00 2001 From: nowrap Date: Wed, 19 Aug 2026 08:58:14 +0200 Subject: [PATCH 2/3] poll: ask the usage endpoint no more often than its answer changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refresh interval was the only thing deciding how often a provider is reached, so a 90 s tray was making 40 requests an hour against an endpoint with a request quota it does not own alone — for numbers whose windows are five hours and seven days wide. The 429 that earned then froze the display for a quarter of an hour at a time, which is the symptom that gets reported. A provider now states its own floor and the scheduler enforces it, on top of the backoff rather than instead of it: whichever pause is longer wins. Claude's floor is claude.minIntervalSeconds, 300 s by default and in the settings dialog; Codex keeps zero, being a local process with nothing to spend. Reset() clears the spacing along with the backoff, so the menu's refresh command still reaches everything at once — someone asking for numbers now is entitled to the request. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 ++ README.de.md | 3 +- README.md | 3 +- src/RateTray/Configuration/AppConfig.cs | 9 ++ src/RateTray/Localization/de.json | 1 + src/RateTray/Localization/en.json | 1 + src/RateTray/Providers/ClaudeUsageProvider.cs | 2 + src/RateTray/Providers/IUsageProvider.cs | 8 ++ src/RateTray/Providers/PollScheduler.cs | 39 ++++++- src/RateTray/TrayApp.cs | 20 +++- src/RateTray/Ui/SettingsForm.cs | 4 + tests/RateTray.Tests/PollSchedulerTests.cs | 105 +++++++++++++++++- 12 files changed, 191 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dd170e..e4e6f1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- The usage endpoint is asked at most once every five minutes, whatever the refresh interval is + set to. A weekly window does not move in ninety seconds, and the tray is not the only client + spending the account's request quota — so polling that bought nothing was earning the 429 that + then froze the display for a quarter of an hour at a time. `claude.minIntervalSeconds` sets the + floor and the settings dialog exposes it; the menu's refresh command ignores it, because + someone asking for numbers now is entitled to the request. Codex is unaffected: it is a local + process with nothing to spend. - A Codex plan with a per-model quota says which model each limit belongs to. Two weekly windows both read "Codex · Week", so the model's own limit — sitting at 0 % until that model runs — looked like the account's limit had stopped filling. The model's window is now labelled after diff --git a/README.de.md b/README.de.md index f009934..a9fbd3f 100644 --- a/README.de.md +++ b/README.de.md @@ -116,7 +116,8 @@ Alles lässt sich auch direkt in `%APPDATA%\RateTray\settings.json` bearbeiten: "unknown": null, "shadeSpread": 0.15 // Abstufung zwischen Limits eines Dienstes; 0 = aus }, - "claude": { "enabled": true, "autoRefreshToken": false, "timeoutSeconds": 20 }, + "claude": { "enabled": true, "autoRefreshToken": false, "timeoutSeconds": 20, + "minIntervalSeconds": 300 }, // Mindestabstand zwischen Usage-Abfragen "codex": { "enabled": true, "executablePath": null, "timeoutSeconds": 30 } } ``` diff --git a/README.md b/README.md index 25d6b4e..c7b2d7c 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,8 @@ Everything is also editable directly in `%APPDATA%\RateTray\settings.json`: "unknown": null, "shadeSpread": 0.15 // how far limits of one service are shaded apart; 0 = off }, - "claude": { "enabled": true, "autoRefreshToken": false, "timeoutSeconds": 20 }, + "claude": { "enabled": true, "autoRefreshToken": false, "timeoutSeconds": 20, + "minIntervalSeconds": 300 }, // floor under how often the usage endpoint is asked "codex": { "enabled": true, "executablePath": null, "timeoutSeconds": 30 } } ``` diff --git a/src/RateTray/Configuration/AppConfig.cs b/src/RateTray/Configuration/AppConfig.cs index 9321947..31a87e9 100644 --- a/src/RateTray/Configuration/AppConfig.cs +++ b/src/RateTray/Configuration/AppConfig.cs @@ -214,6 +214,14 @@ public sealed class ClaudeOptions /// How long to wait for the usage endpoint before giving up on a poll. public int TimeoutSeconds { get; set; } = 20; + /// + /// Shortest gap between two requests to the usage endpoint, whatever the refresh interval + /// is set to. The endpoint has a request quota and the numbers behind it move in hours, so + /// asking it every ninety seconds spends the allowance without ever learning anything new — + /// and the 429 that follows costs the display for a quarter of an hour. 0 removes the floor. + /// + public int MinIntervalSeconds { get; set; } = 300; + /// /// Off by default: while Claude Code runs it keeps the token fresh on disk and we /// simply re-read it. Turning this on lets the tray refresh the OAuth token itself @@ -233,6 +241,7 @@ internal ClaudeOptions Normalize() TokenUrl = Sane.Text(TokenUrl, TokenUrlDefault); ClientId = Sane.Text(ClientId, ClientIdDefault); TimeoutSeconds = Math.Clamp(TimeoutSeconds, 5, 300); + MinIntervalSeconds = Math.Clamp(MinIntervalSeconds, 0, 3_600); return this; } } diff --git a/src/RateTray/Localization/de.json b/src/RateTray/Localization/de.json index e975156..27f6717 100644 --- a/src/RateTray/Localization/de.json +++ b/src/RateTray/Localization/de.json @@ -32,6 +32,7 @@ "settings.icons.rediscover": "Limits beim nächsten Abruf neu ermitteln", "settings.claude.enabled": "Claude-Limits abrufen", "settings.claude.autoRefresh": "OAuth-Token selbst erneuern (ungetesteter Pfad)", + "settings.claude.minInterval": "Mindestabstand zwischen Abfragen (Sekunden)", "settings.claude.timeout": "Usage-API-Timeout (Sekunden)", "settings.claude.credentials": "Anmeldedatei (leer = Standard)", "settings.codex.enabled": "Codex-Limits abrufen", diff --git a/src/RateTray/Localization/en.json b/src/RateTray/Localization/en.json index e40fb07..725e504 100644 --- a/src/RateTray/Localization/en.json +++ b/src/RateTray/Localization/en.json @@ -32,6 +32,7 @@ "settings.icons.rediscover": "Re-discover limits on next poll", "settings.claude.enabled": "Read Claude limits", "settings.claude.autoRefresh": "Refresh the OAuth token myself (untested path)", + "settings.claude.minInterval": "Min. gap between usage requests (seconds)", "settings.claude.timeout": "Usage API timeout (seconds)", "settings.claude.credentials": "Credentials file (empty = default)", "settings.codex.enabled": "Read Codex limits", diff --git a/src/RateTray/Providers/ClaudeUsageProvider.cs b/src/RateTray/Providers/ClaudeUsageProvider.cs index 16180fd..f0a3c3b 100644 --- a/src/RateTray/Providers/ClaudeUsageProvider.cs +++ b/src/RateTray/Providers/ClaudeUsageProvider.cs @@ -29,6 +29,8 @@ public sealed class ClaudeUsageProvider(ClaudeOptions options) : IUsageProvider public bool Enabled => options.Enabled; + public TimeSpan MinInterval => TimeSpan.FromSeconds(Math.Max(0, options.MinIntervalSeconds)); + private string CredentialsPath => options.CredentialsPath is { Length: > 0 } p ? Environment.ExpandEnvironmentVariables(p) : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", ".credentials.json"); diff --git a/src/RateTray/Providers/IUsageProvider.cs b/src/RateTray/Providers/IUsageProvider.cs index fa5e136..257d0a6 100644 --- a/src/RateTray/Providers/IUsageProvider.cs +++ b/src/RateTray/Providers/IUsageProvider.cs @@ -9,5 +9,13 @@ public interface IUsageProvider bool Enabled { get; } + /// + /// Shortest gap this provider may be polled at, regardless of the configured refresh + /// interval. A local process can be asked as often as the timer ticks; an endpoint with a + /// request quota cannot, and the tray is not the only thing spending it. Zero means the + /// refresh interval alone decides. + /// + TimeSpan MinInterval => TimeSpan.Zero; + Task ReadAsync(CancellationToken ct); } diff --git a/src/RateTray/Providers/PollScheduler.cs b/src/RateTray/Providers/PollScheduler.cs index 4fe5e0d..3129744 100644 --- a/src/RateTray/Providers/PollScheduler.cs +++ b/src/RateTray/Providers/PollScheduler.cs @@ -15,10 +15,28 @@ public sealed class PollScheduler(int maxBackoffMinutes = 15) { private readonly Dictionary _failures = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _retryAt = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _minInterval = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _polledAt = new(StringComparer.OrdinalIgnoreCase); - /// False while the provider is backed off. Only a real poll may follow a true. - public bool ShouldPoll(string group, DateTimeOffset now) => - !_retryAt.TryGetValue(group, out var until) || now >= until; + /// + /// Sets a floor under how often this provider is asked, independent of the refresh + /// interval and of whether it is failing. Configuration rather than state: it survives + /// , which only releases what a failure or a poll put there. + /// + public void SetMinInterval(string group, TimeSpan interval) => + _minInterval[group] = interval > TimeSpan.Zero ? interval : TimeSpan.Zero; + + /// + /// False while the provider is backed off, and false again until its minimum gap has + /// passed. Only a real poll may follow a true. + /// + public bool ShouldPoll(string group, DateTimeOffset now) + { + if (_retryAt.TryGetValue(group, out var until) && now < until) return false; + + return !_polledAt.TryGetValue(group, out var last) + || now >= last + _minInterval.GetValueOrDefault(group); + } /// When the provider will be tried again, or null if it is not backed off. public DateTimeOffset? RetryAt(string group) => @@ -26,10 +44,15 @@ public bool ShouldPoll(string group, DateTimeOffset now) => public int Failures(string group) => _failures.GetValueOrDefault(group); - public void RecordSuccess(string group) + /// When this provider was last actually reached, or null if it never was. + public DateTimeOffset? PolledAt(string group) => + _polledAt.TryGetValue(group, out var last) ? last : null; + + public void RecordSuccess(string group, DateTimeOffset now) { _failures.Remove(group); _retryAt.Remove(group); + _polledAt[group] = now; } /// @@ -42,17 +65,23 @@ public DateTimeOffset RecordFailure(string group, TimeSpan? statedDelay, DateTim { var failures = _failures.GetValueOrDefault(group) + 1; _failures[group] = failures; + _polledAt[group] = now; var until = now + Backoff(statedDelay, failures, refreshSeconds, maxBackoffMinutes, rateLimited); _retryAt[group] = until; return until; } - /// Clears every pause, so an explicit refresh reaches all providers at once. + /// + /// Clears every pause — the backoff and the minimum gap alike — so an explicit refresh + /// reaches all providers at once. Someone who asks for numbers now is entitled to the + /// request, even where the tray would have spaced it out on its own. + /// public void Reset() { _failures.Clear(); _retryAt.Clear(); + _polledAt.Clear(); } /// diff --git a/src/RateTray/TrayApp.cs b/src/RateTray/TrayApp.cs index 63d63a4..ed78644 100644 --- a/src/RateTray/TrayApp.cs +++ b/src/RateTray/TrayApp.cs @@ -86,6 +86,7 @@ public TrayApp() _lastGood = UsageCache.Load(); _schedule = new PollScheduler(_config.MaxBackoffMinutes); + ApplyPollSpacing(); BuildMenu(); _menu.Opened += (_, _) => HideTooltip(); @@ -199,15 +200,16 @@ private void ShowCachedReadings() /// private ProviderResult RememberAndRestore(ProviderResult result) { + var now = DateTimeOffset.Now; + if (result.Ok) { - _lastGood[result.Group] = new CachedReadings(DateTimeOffset.Now, result.Readings); - _schedule.RecordSuccess(result.Group); + _lastGood[result.Group] = new CachedReadings(now, result.Readings); + _schedule.RecordSuccess(result.Group, now); UsageCache.Save(_lastGood); return result; } - var now = DateTimeOffset.Now; var retryAt = _schedule.RecordFailure( result.Group, result.RetryAfter, now, _config.RefreshSeconds, result.RateLimited); @@ -228,6 +230,17 @@ private ProviderResult RememberAndRestore(ProviderResult result) private void ScheduleNextPoll() => _nextPoll = DateTimeOffset.Now.AddMilliseconds(_timer.Interval); + /// + /// Hands each provider's own floor to the scheduler, so the timer may tick as often as the + /// fastest of them allows. Asked of the providers rather than decided here: whether a poll + /// costs anything is a property of what is being polled — a local process that answers for + /// free, or a metered endpoint the tray is not the only client of. + /// + private void ApplyPollSpacing() + { + foreach (var provider in _providers) _schedule.SetMinInterval(provider.Group, provider.MinInterval); + } + private ProviderResult LastResultFor(string group) => _lastResults.FirstOrDefault(r => r.Group == group) ?? ProviderResult.Failed(group, Loc.T("error.backoff")); @@ -614,6 +627,7 @@ private void OpenSettings() Loc.Use(_config.Language); _timer.Interval = PollIntervalMs; + ApplyPollSpacing(); ScheduleNextPoll(); // Both windows cache fonts and colours at construction, so they are rebuilt rather diff --git a/src/RateTray/Ui/SettingsForm.cs b/src/RateTray/Ui/SettingsForm.cs index 34dadaa..fa0f434 100644 --- a/src/RateTray/Ui/SettingsForm.cs +++ b/src/RateTray/Ui/SettingsForm.cs @@ -47,6 +47,7 @@ public sealed class SettingsForm : Form private readonly CheckBox _codexEnabled = new(); private readonly TextBox _codexPath = new(); private readonly NumericUpDown _claudeTimeout = new() { Minimum = 5, Maximum = 300 }; + private readonly NumericUpDown _claudeMinInterval = new() { Minimum = 0, Maximum = 3600, Increment = 30 }; private readonly NumericUpDown _codexTimeout = new() { Minimum = 5, Maximum = 300 }; private Color _claudeValue; @@ -162,6 +163,7 @@ private TabPage ServicesPage() AddFullRow(grid, Check(_claudeAutoRefresh, Loc.T("settings.claude.autoRefresh"))); AddRow(grid, Loc.T("settings.claude.credentials"), WithBrowse(_claudeCredentials)); AddRow(grid, Loc.T("settings.claude.timeout"), _claudeTimeout); + AddRow(grid, Loc.T("settings.claude.minInterval"), _claudeMinInterval); AddFullRow(grid, new Label { Height = 8, Dock = DockStyle.Fill }); AddFullRow(grid, Check(_codexEnabled, Loc.T("settings.codex.enabled"))); AddRow(grid, Loc.T("settings.codex.path"), WithBrowse(_codexPath)); @@ -350,6 +352,7 @@ private void ReadConfig() _claudeAutoRefresh.Checked = _config.Claude.AutoRefreshToken; _claudeCredentials.Text = _config.Claude.CredentialsPath ?? ""; _claudeTimeout.Value = Math.Clamp(_config.Claude.TimeoutSeconds, 5, 300); + _claudeMinInterval.Value = Math.Clamp(_config.Claude.MinIntervalSeconds, 0, 3600); _codexEnabled.Checked = _config.Codex.Enabled; _codexPath.Text = _config.Codex.ExecutablePath ?? ""; _codexTimeout.Value = Math.Clamp(_config.Codex.TimeoutSeconds, 5, 300); @@ -384,6 +387,7 @@ private void WriteConfig() _config.Claude.AutoRefreshToken = _claudeAutoRefresh.Checked; _config.Claude.CredentialsPath = Empty(_claudeCredentials.Text); _config.Claude.TimeoutSeconds = (int)_claudeTimeout.Value; + _config.Claude.MinIntervalSeconds = (int)_claudeMinInterval.Value; _config.Codex.Enabled = _codexEnabled.Checked; _config.Codex.ExecutablePath = Empty(_codexPath.Text); _config.Codex.TimeoutSeconds = (int)_codexTimeout.Value; diff --git a/tests/RateTray.Tests/PollSchedulerTests.cs b/tests/RateTray.Tests/PollSchedulerTests.cs index cb6a38b..459e94b 100644 --- a/tests/RateTray.Tests/PollSchedulerTests.cs +++ b/tests/RateTray.Tests/PollSchedulerTests.cs @@ -73,7 +73,7 @@ public void Success_clears_the_pause_and_the_failure_count() var schedule = new PollScheduler(); schedule.RecordFailure("Claude", null, Start, 90); - schedule.RecordSuccess("Claude"); + schedule.RecordSuccess("Claude", Start); Assert.True(schedule.ShouldPoll("Claude", Start)); Assert.Null(schedule.RetryAt("Claude")); @@ -116,6 +116,109 @@ public void After_a_reset_the_backoff_starts_over_rather_than_where_it_left_off( Assert.Equal(Start.AddSeconds(180), retryAt); } + // ---------------------------------------------------------- minimum spacing + + /// + /// The reason this exists: the usage endpoint has a request quota, the tray was asking it + /// every ninety seconds for numbers that move in hours, and the 429 that earned cost the + /// display a quarter of an hour at a time. + /// + [Fact] + public void A_provider_with_a_minimum_gap_is_skipped_until_it_has_passed() + { + var schedule = new PollScheduler(); + schedule.SetMinInterval("Claude", TimeSpan.FromMinutes(5)); + + schedule.RecordSuccess("Claude", Start); + + Assert.False(schedule.ShouldPoll("Claude", Start.AddSeconds(90))); + Assert.False(schedule.ShouldPoll("Claude", Start.AddSeconds(299))); + Assert.True(schedule.ShouldPoll("Claude", Start.AddSeconds(300))); + } + + [Fact] + public void The_gap_is_measured_from_a_failed_poll_too() + { + // Whether the answer was usable does not change what the request cost. + var schedule = new PollScheduler(); + schedule.SetMinInterval("Claude", TimeSpan.FromMinutes(30)); + + schedule.RecordFailure("Claude", null, Start, refreshSeconds: 90); + + Assert.False(schedule.ShouldPoll("Claude", Start.AddMinutes(29))); + Assert.True(schedule.ShouldPoll("Claude", Start.AddMinutes(30))); + } + + [Fact] + public void Skipping_for_the_gap_does_not_count_as_a_failure() + { + var schedule = new PollScheduler(); + schedule.SetMinInterval("Claude", TimeSpan.FromMinutes(5)); + schedule.RecordSuccess("Claude", Start); + + for (var tick = 1; tick <= 3; tick++) schedule.ShouldPoll("Claude", Start.AddSeconds(90d * tick)); + + Assert.Equal(0, schedule.Failures("Claude")); + Assert.Null(schedule.RetryAt("Claude")); + } + + [Fact] + public void A_provider_without_a_gap_is_polled_every_cycle() + { + var schedule = new PollScheduler(); + schedule.RecordSuccess("Codex", Start); + + Assert.True(schedule.ShouldPoll("Codex", Start)); + } + + [Fact] + public void The_first_poll_is_never_held_back() + { + var schedule = new PollScheduler(); + schedule.SetMinInterval("Claude", TimeSpan.FromHours(1)); + + Assert.True(schedule.ShouldPoll("Claude", Start)); + Assert.Null(schedule.PolledAt("Claude")); + } + + [Fact] + public void An_explicit_refresh_beats_the_gap() + { + // Someone asking for numbers now is entitled to the request; the floor is there to + // stop the timer spending the quota, not to overrule the person watching. + var schedule = new PollScheduler(); + schedule.SetMinInterval("Claude", TimeSpan.FromMinutes(5)); + schedule.RecordSuccess("Claude", Start); + + schedule.Reset(); + + Assert.True(schedule.ShouldPoll("Claude", Start)); + } + + [Fact] + public void The_gap_survives_a_reset_because_it_is_configuration() + { + var schedule = new PollScheduler(); + schedule.SetMinInterval("Claude", TimeSpan.FromMinutes(5)); + + schedule.Reset(); + schedule.RecordSuccess("Claude", Start); + + Assert.False(schedule.ShouldPoll("Claude", Start.AddMinutes(4))); + } + + [Fact] + public void A_backoff_longer_than_the_gap_still_wins() + { + var schedule = new PollScheduler(); + schedule.SetMinInterval("Claude", TimeSpan.FromMinutes(1)); + + schedule.RecordFailure("Claude", null, Start, refreshSeconds: 90, rateLimited: true); + + Assert.False(schedule.ShouldPoll("Claude", Start.AddMinutes(14))); + Assert.True(schedule.ShouldPoll("Claude", Start.AddMinutes(15))); + } + // ----------------------------------------------------------------- backoff [Fact] From d94aa9b61071d09a4030b80c2a0eb7c993ef3e78 Mon Sep 17 00:00:00 2001 From: nowrap Date: Wed, 19 Aug 2026 09:51:59 +0200 Subject: [PATCH 3/3] codex: take the default bucket's id from the payload, not from a literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rateLimitsByLimitId` repeats the bucket `rateLimits` already carried, so one of them has to be skipped, and the check matched the literal "codex". That is the id this account reports today; it has reported "premium" too. Under that name neither branch would have skipped anything and the one limit would have been listed twice — as two rows a person cannot tell apart, which is the failure this file just spent a commit fixing. The payload states the id. Use it. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 ++++ src/RateTray/Providers/CodexUsageProvider.cs | 12 +++++++++-- .../RateTray.Tests/CodexUsageProviderTests.cs | 20 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4e6f1f..6d9526b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). floor and the settings dialog exposes it; the menu's refresh command ignores it, because someone asking for numbers now is entitled to the request. Codex is unaffected: it is a local process with nothing to spend. +- The account-wide Codex bucket is recognised by the id the payload states rather than by the + literal "codex". It has answered `premium` on this account before, and under that name it + would have been listed twice — once from `rateLimits`, once from `rateLimitsByLimitId` — as + two rows for one limit. - A Codex plan with a per-model quota says which model each limit belongs to. Two weekly windows both read "Codex · Week", so the model's own limit — sitting at 0 % until that model runs — looked like the account's limit had stopped filling. The model's window is now labelled after diff --git a/src/RateTray/Providers/CodexUsageProvider.cs b/src/RateTray/Providers/CodexUsageProvider.cs index 4d3dac1..fe1c0e0 100644 --- a/src/RateTray/Providers/CodexUsageProvider.cs +++ b/src/RateTray/Providers/CodexUsageProvider.cs @@ -148,9 +148,11 @@ internal static List Parse(JsonObject result, out string? planType var readings = new List(); // `rateLimits` is the single-bucket view; `rateLimitsByLimitId` may carry more. + string? covered = null; if (result["rateLimits"] is JsonObject primaryBucket) { planType = primaryBucket["planType"]?.GetValue(); + covered = primaryBucket["limitId"]?.GetValue() ?? "codex"; AddWindows(readings, primaryBucket, "codex", planType); } @@ -158,8 +160,14 @@ internal static List Parse(JsonObject result, out string? planType { foreach (var (limitId, node) in buckets) { - // The default bucket is already covered by `rateLimits` above. - if (node is not JsonObject bucket || limitId.Equals("codex", StringComparison.OrdinalIgnoreCase)) continue; + if (node is not JsonObject bucket) continue; + + // Whatever the account-wide bucket is called on this plan — it answers as + // "codex" here and has answered "premium" before — `rateLimits` already + // covered it. Matching the literal instead of the id the payload states would + // list that one limit a second time, as a row identical to the first. + if (covered is { } id && limitId.Equals(id, StringComparison.OrdinalIgnoreCase)) continue; + AddWindows(readings, bucket, $"codex.{Slug(limitId)}", bucket["planType"]?.GetValue()); } } diff --git a/tests/RateTray.Tests/CodexUsageProviderTests.cs b/tests/RateTray.Tests/CodexUsageProviderTests.cs index a659006..fcd83cb 100644 --- a/tests/RateTray.Tests/CodexUsageProviderTests.cs +++ b/tests/RateTray.Tests/CodexUsageProviderTests.cs @@ -102,6 +102,26 @@ public void Parse_marks_only_the_account_wide_window_as_active() Assert.False(readings.Single(r => r.Id == "codex.codex_spark.primary").IsActive); } + [Fact] + public void Parse_recognises_the_default_bucket_by_the_id_the_payload_states() + { + // The account-wide bucket is not always called "codex": this account has reported + // "premium" too. Skipping the literal rather than the stated id would list that one + // limit twice — once from rateLimits, once from the map — as two identical rows. + const string payload = """ + { + "rateLimits": { "limitId": "premium", "primary": { "usedPercent": 6, "windowDurationMins": 10080 }, "planType": "plus" }, + "rateLimitsByLimitId": { + "premium": { "limitId": "premium", "primary": { "usedPercent": 6, "windowDurationMins": 10080 }, "planType": "plus" } + } + } + """; + + var reading = Assert.Single(CodexUsageProvider.Parse(Json(payload), out _)); + + Assert.Equal("codex.primary", reading.Id); + } + [Fact] public void Parse_skips_a_window_without_a_used_percentage() {