ADR 007 - Rate-limit and usage-log accounting refinements
- Status: accepted
- Date: 2026-07-15
Context
M5 shipped admission control (RPM/TPM quotas, hard budgets) and the usage log
with four documented behaviours flagged in docs/backlog.md for revisiting
(issue #26):
- TPM debited the pre-call estimate and never adjusted it. A chat request
with
max_tokens = 2048but 40 real output tokens permanently burned 2048 tokens of the per-minute window, starving later requests. - A request rejected by a later admission step still counted toward the
earlier quotas.
admitbumps RPM, then TPM, then the budget CAS; when the budget refused (402), the RPM and TPM bumps it had already made were never unwound, so a refused request consumed quota it never used. - The usage log recorded successful requests only. A refusal at admission
(402/429) errored out of
Accounting::beginbefore anyUsageRecordwas built, so per-key rejection analytics were impossible. - Metadata values were stringified in
usage_log.metadata({"batch":"42"}), losing the original JSON type and blocking numeric filtering.
The budget already had the right shape: reserve the estimate, then
Reservation::settle to the real cost (over-reservation released, shortfall
charged, real cost wins even past the limit). These refinements bring TPM and
the usage log up to the same standard, without violating the hot-path rules
(no blocking, no synchronous DB write on the request path).
Decision
1. TPM is settled to real usage, like the budget
Reservation now carries the tokens it debited to the TPM window (and the
minute it debited them in). settle(actual_cost_micro, actual_tokens) adjusts
the TPM window by actual_tokens - estimate, mirroring the budget: a smaller
real count frees the window, a larger one overshoots and the next request is
refused. The adjustment is a CAS loop (adjust_window) and is a no-op once the
window has rolled to a new minute (the debited slot has already expired).
Asymmetry with the budget on drop. Dropping a reservation unsettled (upstream
failure / cancellation before settle) refunds the budget (no money was
spent) but keeps the TPM debit. TPM is a rate limiter: a request that hit
the gateway counts against the per-minute rate even if the upstream call then
failed. Only a successful settle, which knows the real token count, adjusts
TPM. This preserves the established M5 principle (“refused/failed requests still
count toward the rate”) while fixing the starvation caused by over-estimates on
successful calls, which is the common case.
2. Quota bumps are unwound when a later step rejects
Within a single admit, if the TPM step refuses after RPM was bumped, the RPM
bump is rolled back; if the budget step refuses after RPM and TPM were bumped,
both are rolled back. A request refused inside admission therefore consumes no
quota. (A request refused at its own step - e.g. RPM over the cap - never
bumped that window in the first place, since bump_window checks before
incrementing.) Rollback uses the same adjust_window CAS helper and the same
minute guard.
3. Rejected requests produce a status-only usage row
When admission refuses, Accounting::begin enqueues a UsageRecord with the
rejection status (402/429), zero tokens, zero cost, and any request metadata,
then returns the error. It goes through the same bounded-mpsc UsageLogger
as successful requests (try_send; a full channel drops and counts the row) -
never a synchronous DB write on the request path (the M5 hot-path rule stands).
Zero tokens is the honest count: a rejected request reached no provider and
produced nothing. The status column is what carries the rejection for
analytics. Scope note: 401 (unknown/invalid key) is not logged - it is
refused in the auth middleware before begin, where there is no key to
attribute the row to. Upstream failures (5xx) after admission are already
logged by the normal finish / finalize_with_status paths.
4. Metadata keeps its JSON value types
RequestMetadata stores Vec<(String, serde_json::Value)> (validated to
string/number/bool) instead of pre-stringified strings. to_json - the source
of the usage_log.metadata TEXT column - now emits typed JSON
({"batch":42,"canary":true}), so SQLite json_extract can filter
numerically. Prometheus labels, which are always strings, stringify on the way
out (label_values returns Cow<str>, borrowing the string case and only
allocating for numbers/bools). The column stays TEXT; only its contents gained
type fidelity, so no migration is needed.
Consequences
- The TPM window now tracks real usage for the common (successful) case, so
large
max_tokensreservations no longer starve a key for a full minute. - Rejection analytics are possible per key (
SELECT status, COUNT(*) FROM usage_log ... WHERE status IN (402, 429)), at the cost of extra usage-log volume during quota storms - bounded by the same channel capacity and drop counter as everything else. Reservation::settlegained a parameter; all call sites pass the real total token count (tokens_in + tokens_out).usage_log.metadatamay now contain non-string JSON values; consumers that assumed all-strings should read it as typed JSON.