Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### 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.
- 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
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
Expand Down
3 changes: 2 additions & 1 deletion README.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
```
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
```
Expand Down
9 changes: 9 additions & 0 deletions src/RateTray/Configuration/AppConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,14 @@ public sealed class ClaudeOptions
/// <summary>How long to wait for the usage endpoint before giving up on a poll.</summary>
public int TimeoutSeconds { get; set; } = 20;

/// <summary>
/// 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.
/// </summary>
public int MinIntervalSeconds { get; set; } = 300;

/// <summary>
/// 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
Expand All @@ -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;
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/RateTray/Localization/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -107,6 +108,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)",
Expand Down
2 changes: 2 additions & 0 deletions src/RateTray/Localization/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -107,6 +108,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)",
Expand Down
2 changes: 2 additions & 0 deletions src/RateTray/Providers/ClaudeUsageProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
37 changes: 32 additions & 5 deletions src/RateTray/Providers/CodexUsageProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,18 +148,26 @@ internal static List<LimitReading> Parse(JsonObject result, out string? planType
var readings = new List<LimitReading>();

// `rateLimits` is the single-bucket view; `rateLimitsByLimitId` may carry more.
string? covered = null;
if (result["rateLimits"] is JsonObject primaryBucket)
{
planType = primaryBucket["planType"]?.GetValue<string>();
covered = primaryBucket["limitId"]?.GetValue<string>() ?? "codex";
AddWindows(readings, primaryBucket, "codex", planType);
}

if (result["rateLimitsByLimitId"] is JsonObject buckets)
{
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<string>());
}
}
Expand All @@ -170,8 +178,17 @@ internal static List<LimitReading> Parse(JsonObject result, out string? planType
.ToList();
}

/// <summary>
/// Turns one bucket's windows into readings. Every bucket but the account-wide one belongs
/// to a single model and names it in <c>limitName</c>; 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.
/// </summary>
private static void AddWindows(List<LimitReading> readings, JsonObject bucket, string prefix, string? planType)
{
var accountWide = prefix == "codex";
var limitName = bucket["limitName"]?.GetValue<string>();

foreach (var slot in (string[])["primary", "secondary"])
{
if (bucket[slot] is not JsonObject window) continue;
Expand All @@ -180,17 +197,27 @@ private static void AddWindows(List<LimitReading> readings, JsonObject bucket, s
var minutes = window["windowDurationMins"]?.GetValue<long?>();
var span = minutes is > 0 ? TimeSpan.FromMinutes(minutes.Value) : (TimeSpan?)null;
var resets = window["resetsAt"]?.GetValue<long?>();
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 · <model>", 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<double>(),
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,
});
}
}
Expand Down
8 changes: 8 additions & 0 deletions src/RateTray/Providers/IUsageProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,13 @@ public interface IUsageProvider

bool Enabled { get; }

/// <summary>
/// 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.
/// </summary>
TimeSpan MinInterval => TimeSpan.Zero;

Task<ProviderResult> ReadAsync(CancellationToken ct);
}
39 changes: 34 additions & 5 deletions src/RateTray/Providers/PollScheduler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,44 @@ public sealed class PollScheduler(int maxBackoffMinutes = 15)
{
private readonly Dictionary<string, int> _failures = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, DateTimeOffset> _retryAt = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, TimeSpan> _minInterval = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, DateTimeOffset> _polledAt = new(StringComparer.OrdinalIgnoreCase);

/// <summary>False while the provider is backed off. Only a real poll may follow a true.</summary>
public bool ShouldPoll(string group, DateTimeOffset now) =>
!_retryAt.TryGetValue(group, out var until) || now >= until;
/// <summary>
/// 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
/// <see cref="Reset"/>, which only releases what a failure or a poll put there.
/// </summary>
public void SetMinInterval(string group, TimeSpan interval) =>
_minInterval[group] = interval > TimeSpan.Zero ? interval : TimeSpan.Zero;

/// <summary>
/// False while the provider is backed off, and false again until its minimum gap has
/// passed. Only a real poll may follow a true.
/// </summary>
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);
}

/// <summary>When the provider will be tried again, or null if it is not backed off.</summary>
public DateTimeOffset? RetryAt(string group) =>
_retryAt.TryGetValue(group, out var until) ? until : null;

public int Failures(string group) => _failures.GetValueOrDefault(group);

public void RecordSuccess(string group)
/// <summary>When this provider was last actually reached, or null if it never was.</summary>
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;
}

/// <summary>
Expand All @@ -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;
}

/// <summary>Clears every pause, so an explicit refresh reaches all providers at once.</summary>
/// <summary>
/// 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.
/// </summary>
public void Reset()
{
_failures.Clear();
_retryAt.Clear();
_polledAt.Clear();
}

/// <summary>
Expand Down
20 changes: 17 additions & 3 deletions src/RateTray/TrayApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ public TrayApp()

_lastGood = UsageCache.Load();
_schedule = new PollScheduler(_config.MaxBackoffMinutes);
ApplyPollSpacing();

BuildMenu();
_menu.Opened += (_, _) => HideTooltip();
Expand Down Expand Up @@ -199,15 +200,16 @@ private void ShowCachedReadings()
/// </summary>
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);

Expand All @@ -228,6 +230,17 @@ private ProviderResult RememberAndRestore(ProviderResult result)

private void ScheduleNextPoll() => _nextPoll = DateTimeOffset.Now.AddMilliseconds(_timer.Interval);

/// <summary>
/// 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.
/// </summary>
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"));
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/RateTray/Ui/SettingsForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Loading