Fix simulated 5h/7d rate-limit buckets: account-wide usage, real countdown, cheaper log, and buckets that reach the renderer - #127
Merged
Conversation
The [rate_limits] simulator produced 0% for any freshly-started session and a countdown pinned near the full window, because of three separate defects. RateLimitLog.usage_since was scoped to a single session_id, but 5h/7d limits are account-wide. With two concurrent sessions the reported figure was 3,973 tokens against a true cross-session total of ~125,000. It also computed in_window[-1] - in_window[0], discarding the first in-window sample's cumulative value; for a session that started inside the window that sample is the bulk of its usage. It now groups the log by session, baselines each one against its last sample *before* the window (0 when it has none), and sums the per-session deltas. _rolling_bucket set resets_at = now + window_seconds, recomputed every render, so the countdown never counted down and the window never ended. anchor = "rolling" now means a window anchored at first account-wide activity, shared by every concurrent session and derived from the existing rate-limit log rather than new state: the window runs its full length regardless of idle time, and only advances once it has actually elapsed. A session joining after a 3h break sees the same anchor and the remaining 2h, not a fresh window. This redefines the documented meaning of anchor = "rolling"; the previous relative-to-now behaviour was the bug, so there is no prior semantics worth preserving under that name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hanges The account-wide fix in 0a9d33d made every render scan the whole log instead of one session's slice, and _rolling_bucket read it twice (once for the anchor, once for the usage). With refreshInterval: 1 that showed up as a 1.18x regression: 72.1ms against main's 61.1ms. Two changes. RateLimitLog now parses the file once per render and threads that structure through simulate_rate_limits into both bucket helpers, so the anchor and the usage come from the same pass; the lapsed-window loop walks the sorted timestamps with an index instead of rebuilding a generator per iteration. record() then stops appending when a session's cumulative value is unchanged from its last line. On a real log only 229 of 5,927 lines (3.9%) changed a value -- the rest were idle heartbeats repeating the previous total, and each one triggered a full read-parse-rewrite of a 300KB file. A session's first line is always written; pruning still happens on the ticks that do write. Dedupe also fixes a latent bug in the anchor: timestamps now mark real token spend rather than idle ticks, so an idle session can no longer open a fresh window having consumed nothing. make bench: 58.9ms against main's 63.7ms, 1.08x faster. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tmck-code
marked this pull request as ready for review
September 3, 2026 00:21
…ntext-window gauge _apply_rate_limit_sim built its cumulative-token signal from context_window.total_input_tokens + total_output_tokens, but that field is the composition of only the single most recent request, not a lifetime total. Diffing consecutive samples of it undercounted real consumption by roughly the turn count in the window (every turn re-reads the whole cached prefix), and the value drops on /compact or /clear -- which the existing max(0, ...) clamp silently swallowed, reading as zero usage until context climbed back past its old peak. TranscriptUsage.gather already sums usage across the whole transcript, deduped by message id, giving a monotonic lifetime total per session. Route that through instead: RateLimitLog now logs the four raw components (input/cache_creation/cache_read/output) rather than one pre-summed number, so the per-component weighting in usage_since can be tuned later without invalidating stored history, and legacy 3-field lines (an incompatible gauge signal) are skipped rather than migrated. usage_since applies a documented-as-an-assumption weighting mirroring the public API's cache-pricing ratios, since Anthropic doesn't publish how Max's 5h/7d windows actually weight cache tokens. To avoid a second transcript parse per render, main() now builds the SessionView (when rate_limit_rules are configured) before calling the simulator, and threads that same view into render() via a new optional parameter, so the one transcript parse feeds both the simulator and the normal session view. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Anthropic doesn't document how Max's 5h/7d windows weight cache/output tokens, so the previous hardcoded weights were a bare assumption baked into code. Expose them as a global, all-optional yas.toml table (default unchanged except output, corrected to 5.0 to match the public API's pricing ratios: output is uniformly 5x base input, not 1x) so users can tune the guess without patching source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_apply_rate_limit_sim` mutated only the raw `info` dict, but `render` reads `view.session.rate_limits`, snapshotted before the mutation. With all-zero real buckets the renderer drew the "unlimited" glyph instead of the simulated percentage. Return the synthesised `RateLimits` and reattach it to the session, keeping one synthesis call per render. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`record()` did a read-all -> rewrite-all of the whole log on every writing tick, unlocked. Two consequences, both observed on a real log: - Legacy 3-field lines are skipped on read but were physically erased by the first 6-field write, so the window lost all pre-switch history and the simulated bucket jumped 0% -> 4% instead of tracking. The class docstring's claim that they "age out on their own" was untrue. - Concurrent writers silently dropped each other's samples: 3 writers x 200 records left 10 of 600 surviving. The write path is now a single append of one line. Compaction moves to `_maybe_compact`, which rewrites only when the oldest row actually predates `keep_seconds`, under an O_EXCL lock with stale-lock recovery, via tmp + os.replace. It filters raw lines by leading timestamp regardless of field count, so legacy lines now genuinely age out via retention. The dedupe and the "log parsed once per render" contract are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`usage_since` baselined a session at (0,0,0,0) when it had no sample at-or-before the window start, charging that session's entire lifetime cumulative to the window -- observed as a 0 -> 1,998,171 jump of which 8.99M cache-read had accrued an hour before the anchor. The baseline is now the session's first in-window sample, so only growth observed inside the window is counted. That also removes the idle-session cliff: a session whose samples all predate the window has cumulative-at-window-start == cumulative-now, so its true delta is 0 and `used_percentage` decays smoothly across a window roll instead of stepping down by that session's whole share. Trade-off, pinned by test: a brand-new session's very first sample is excluded from its own contribution -- a bounded undercount of one sample, replacing an unbounded overcount of a whole lifetime. The sim tests that asserted the old behaviour are updated; three of them now record an explicit zero-baseline sample first, leaving their expected percentages unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`from_transcript` reads only the session's own transcript, so the simulator counted main-thread tokens only. Subagent usage is persisted at `<session>/subagents/agent-*.jsonl`, one flat directory per session covering the whole spawn tree. Measured on a real session: main thread 71,869 billed input vs subagents 244,638 -- the simulator saw 22.7% of true burn. On Bedrock, where these buckets exist purely to give devs burn awareness, that undercount defeats the feature. Adds `TranscriptUsage.__add__` and `from_session`, which sums the main transcript plus every subagent file, discovering the layout at runtime (missing `subagents/` falls back to main-only rather than crashing) and applying no sidechain filter -- those records are all `isSidechain:true` and filtering would zero them. Subagent files are read through `TranscriptCache` on the same (mtime, size) contract `count_transcript` uses, so repeat renders don't re-parse them. Only the rate-limit call site moves to `from_session`, via a new `SessionView.rate_limit_usage`; the cost row and other `transcript_usage` consumers keep main-only semantics. Every session's reported cumulative rises several-fold, so a previously tuned `budget` needs recalibrating. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
The
[rate_limits]simulator reported 0.0% for the 5H bucket on any freshly-started session, while the day token counters on row 3 showed six figures, and the countdown sat near the full window forever. Three independent defects, all in how the simulated buckets were derived.Bumped to 0.9.2. The weighting defaults are a documented assumption rather than a confirmed rule (see Known limitations), and the table is configurable so users can tune it against their own usage.
Changes
Account-wide usage aggregation
RateLimitLog.usage_sincefiltered the log to a singlesession_id, but 5h/7d limits are account-wide. With two concurrent sessions it reported 3,973 tokens against a true cross-session total of ~125,000.in_window[-1] - in_window[0], discarding the first in-window sample's cumulative value — for a session that started inside the window, that sample is the bulk of its usage.A countdown that actually counts down
_rolling_bucketsetresets_at = now + window_seconds, recomputed every render, so the countdown never moved and the window never ended.anchor = "rolling"now means a window anchored at first account-wide activity, derived from the existing log rather than new state. It runs its full length regardless of idle time and advances only once actually elapsed — a session joining after a 3h break sees the same anchor and the remaining 2h, not a fresh window.anchor = "rolling"value. The previous relative-to-now behaviour was the bug, so there is no prior meaning worth preserving.Log parsed once, written only when it changes
_rolling_bucketread it twice — a 1.18x regression (72.1ms vs 61.1ms) atrefreshInterval: 1.record()no longer appends when a session's cumulative value is unchanged. On a real log 229 of 5,927 lines (3.9%) changed a value; the other 96% were idle heartbeats, each triggering a full read-parse-rewrite of a 300KB file.Correct usage signal
_apply_rate_limit_simfed the bucketscontext_window.total_input_tokens + total_output_tokens. That field is the composition of the most recent request (verified:input + cache_creation + cache_read == total_input_tokens, exactly) — a gauge of current context size, not a lifetime total. Diffing it undercounted real consumption by roughly the number of turns in the window (~50x on a measured session), and a/compactmade it drop, whichmax(0, ...)silently swallowed.monreads.ts session_id input cache_creation cache_read output) rather than one pre-summed number, so weighting can change later without invalidating history. Legacy 3-field lines are skipped on read — their single value is a context-size gauge and cannot be reinterpreted; retention ages them out.Configurable weightings
[rate_limits.weights]table (input,cache_creation,cache_read,output) is resolved once inConfig.load()and threaded through to the aggregation — noConfig.load()in the hot path.[rate_limits.weights]alone is valid but inert, and a config without it behaves identically to the built-in defaults.input = 1.0,cache_creation = 1.25(5-minute cache write),cache_read = 0.1,output = 5.0— output is uniformly 5x base input across every current model.YAS_*env vars: four bespoke lookups for one opt-in table, and the sibling bucket tables have no env form either.Synthesised buckets reach the renderer, not just the payload
_apply_rate_limit_simmutated only the rawinfodict (the persisted payloadmonindexes), butrenderreadsview.session.rate_limits— aSessionInfosnapshotted before the mutation. With all-zero real buckets the statusline drew the "unlimited" glyph on both rows instead of the simulated percentage, even with[rate_limits]configured.RateLimitsand reattaches it to the session, keeping exactly one synthesis call per render.Append-only log: history survives writes
record()did a read-all → rewrite-all of the whole log on every writing tick, unlocked. Legacy 3-field lines are skipped on read but were physically erased by the first 6-field write — so the window lost all pre-switch history and the bucket jumped 0% → 4% instead of tracking. The docstring's claim that they "age out on their own" was untrue._maybe_compact, which rewrites only when the oldest row actually predateskeep_seconds, under anO_EXCLlock with stale-lock recovery, via tmp +os.replace. It filters raw lines by leading timestamp regardless of field count, so legacy lines now genuinely age out via retention. Dedupe and the parse-once-per-render contract are unchanged.os.replaceis still lost, since it goes to the old inode. Milliseconds wide, only on a compacting tick — not worth blocking a statusline's hot path to close.In-window usage is growth, not lifetime (baseline fix)
usage_sincebaselined a session at(0,0,0,0)when it had no sample at-or-before the window start, charging that session's entire lifetime cumulative to the window. Observed as a 0 → 1,998,171 jump, of which 8.99M cache-read had accrued an hour before the anchor.used_percentagedecays smoothly across a window roll instead of stepping down by that session's whole share at once.Subagent tokens are counted
from_transcriptreads only the session's own transcript, so the simulator counted main-thread tokens only. Subagent usage is persisted at<session>/subagents/agent-*.jsonl— one flat directory per session covering the whole spawn tree, including depth-2 and depth-3 agents.TranscriptUsage.from_sessionsums the main transcript plus every subagent file. Layout is discovered at runtime — a missingsubagents/falls back to main-only rather than crashing — and no sidechain filter is applied, since those records are allisSidechain:trueand filtering would zero them (the traptoolcounts.py:55already warns about).TranscriptCacheon the same(mtime, size)contractcount_transcriptuses, so repeat renders don't re-parse them.SessionView.rate_limit_usage. The cost row and othertranscript_usageconsumers keep main-only semantics.Known limitations
The buckets are a synthetic cap, not a model of a real limit. The motivating case is Bedrock, which exposes no 5h/7d token windows at all: the simulator invents one so developers still get a burn signal against a reasonable self-imposed budget. Absolute percentages are therefore indicative by design — what matters is that the weighting is internally consistent, so relative burn between sessions and over time is meaningful.
Where a real limit does exist, the weighting is a proxy. The defaults come from pricing ratios. Anthropic does not publish how a Max subscription's 5h/7d windows weight cache or output tokens — a quota metering raw token counts rather than cost would weight output at 1.0, not 5.0. That single choice moves the output contribution by 5x, which is exactly why the table is configurable.
1-hour cache writes are under-weighted. A 1h cache write is documented at 2x base input, but the transcript layer does not split cache-creation tokens by TTL, so one weight cannot distinguish them. Users relying on 1h caching may want to raise
cache_creationtoward 2.0.Budgets need recalibrating — twice over. Any
budgettuned against the old signal was tuned against numbers ~50x too low. Counting subagent tokens raises reported cumulative by a further several-fold (3.4x on the measured session, more for delegation-heavy work), so an existing budget will now read as near-instant exhaustion. Re-tune before rolling this out.Also unfixed:
aggregate_rate_limits(claude/mon/layout.py:149) reads the raw host payload, somonignores simulated values.Checklist
Screenshots / recording
The final fix changes what the statusline draws when
[rate_limits]is configured: the 5H/7D rows showed the "unlimited" glyph (∞) and now show the simulated percentage and countdown. No renderer/layout/glyph code was touched — only the values fed into existing fields.Benchmark
mainPRRe-run after the subagent-counting change. The 0.5 ms difference is inside one standard deviation. Caveat: the benchmark fixture's session has no
subagents/directory, so this does not exercise the extra file reads — on a real delegation-heavy session the cold-cache render reads up to ~1.4 MB more, absorbed byTranscriptCacheon warm renders.System info
🤖 Generated with Claude Code