Skip to content

fix(proxy): make per-model budgets track spend, enforce, and report the same counter - #37736

Merged
yassin-berriai merged 1 commit into
litellm_internal_stagingfrom
litellm_model_max_budget_usage
Aug 21, 2026
Merged

fix(proxy): make per-model budgets track spend, enforce, and report the same counter#37736
yassin-berriai merged 1 commit into
litellm_internal_stagingfrom
litellm_model_max_budget_usage

Conversation

@yassin-berriai

@yassin-berriai yassin-berriai commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Per-model budget usage stays at zero while spend accrues
  • A key can be blocked at 429 and still report zero usage
  • A Bedrock model never matches a budget keyed on the bare name
  • /user/new accepts model_max_budget and writes an empty dict
  • User-level per-model budgets are enforced by nothing at all
  • Native passthrough spend is neither counted nor capped

How it solves it:

  • One owner for the counter key, the configured budget model
  • Enforcement, the post-call increment and the info endpoints share it
  • A counter the previous release wrote still counts, for its own window
  • Bedrock ids resolve to their family name via the cost map
  • /user/new persists the budget it already echoed back
  • Auth carries the user's budget, so the user scope enforces
  • Passthrough attaches the budget metadata its logging shape drops
  • The dashboard gets a per-model budget editor, which it never had
  • That editor is gated on the license its own write already needs
  • It reads either BudgetConfig spelling and keeps the fields it does not model

User Flow

Before: an admin caps Opus spend per model and the cap silently does nothing, while the dashboard shows the budget sitting at zero usage forever

  1. They POST https://litellm-domain/key/generate with "model_max_budget": {"claude-opus-4-8": {"budget_limit": 0.0001, "time_period": "18h"}} and get a key back carrying that budget
  2. They send three POST https://litellm-domain/v1/chat/completions for bedrock/anthropic.claude-opus-4-8 with that key
  3. All three return 200 with real completions, having spent about 3.4x the configured cap
  4. They GET https://litellm-domain/key/info?key=sk-... and see spend: 0.00034 next to model_max_budget_usage.claude-opus-4-8.current_spend: 0.0
  5. They try the same cap on the alias they route through, anthropic/claude-opus-4-8, and the second request now returns 429 budget_exceeded, but /key/info still reports current_spend: 0.0, so the number they would put on a dashboard disagrees with the number that is rejecting their traffic
  6. They move the cap up to the user instead, POST https://litellm-domain/user/new with the same model_max_budget and a 1mo period, and the response echoes it back to them
  7. They GET https://litellm-domain/user/info?user_id=... and the budget they just set reads model_max_budget: {}, with no usage field at all
  8. Every request that user's keys make is served, with no cap applied anywhere

After: the same caps hold, and the usage the API reports is the usage the proxy is enforcing

  1. They POST https://litellm-domain/key/generate with the same "model_max_budget": {"claude-opus-4-8": {"budget_limit": 0.0001, "time_period": "18h"}}
  2. They send three POST https://litellm-domain/v1/chat/completions for bedrock/anthropic.claude-opus-4-8 with that key
  3. The first returns 200, the second and third return 429 budget_exceeded naming model=bedrock/anthropic.claude-opus-4-8
  4. They GET https://litellm-domain/key/info?key=sk-... and see model_max_budget_usage.claude-opus-4-8.current_spend: 0.0002 against budget_limit: 0.0001, which is exactly what the refusal is based on
  5. The same cap against anthropic/claude-opus-4-8 behaves identically, and /key/info reports the same non-zero usage rather than zero
  6. They POST https://litellm-domain/user/new with the same model_max_budget and a 1mo period
  7. They GET https://litellm-domain/user/info?user_id=... and the budget is there, alongside model_max_budget_usage reporting current-window spend for that model
  8. Their first request is served, the next returns 429 naming LiteLLM User: <user_id>, exceeded budget for model=claude-opus-4-8, and the cap holds across every key that user owns
  9. The same caps now hold on the native passthrough routes: a POST https://litellm-domain/anthropic/v1/messages that used to be billed and ignored counts against model_max_budget and is refused at 429 once the cap is blown
  10. None of the above needs curl any more: https://litellm-domain/ui/?page=api-keys, Create New Key, Optional Settings now carries a Per-Model Budgets control, and the same control is on the key edit and internal-user edit forms

Relevant issues

Linear ticket

Resolves LIT-5894

Pre-Submission checklist

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*, make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Screenshots / Proof of Fix

Shared setup, identical on both sides. Postgres 16 in Docker, a proxy on port 4894 loaded from the worktree, real Anthropic traffic on a real key. bedrock/anthropic.claude-opus-4-8 is a model_list alias pointing at anthropic/claude-opus-4-8, so the model NAME the budget has to match is the Bedrock one while the credential is one I hold.

model_list:
  - model_name: claude-opus-4-8
    litellm_params: {model: anthropic/claude-opus-4-8, api_key: os.environ/ANTHROPIC_API_KEY}
  - model_name: bedrock/anthropic.claude-opus-4-8
    litellm_params: {model: anthropic/claude-opus-4-8, api_key: os.environ/ANTHROPIC_API_KEY}
  - model_name: anthropic/*
    litellm_params: {model: anthropic/*, api_key: os.environ/ANTHROPIC_API_KEY}
general_settings:
  master_key: sk-lit5894

Every leg re-probes /health/readiness after its last request and prints INVALID: proxy died mid-capture if the process went away, so a partially-captured leg cannot be mistaken for a passing one. Both legs below ended healthy.

Before (fc3b160)

A. Key budget keyed claude-opus-4-8, traffic on bedrock/anthropic.claude-opus-4-8

  1. curl -X POST $P/key/generate -H "Authorization: Bearer sk-lit5894" -d '{"user_id": "...", "duration": "18h", "model_max_budget": {"claude-opus-4-8": {"budget_limit": 0.0001, "time_period": "18h"}}}'
  2. Three times: curl -X POST $P/v1/chat/completions -H "Authorization: Bearer $KEY" -d '{"model": "bedrock/anthropic.claude-opus-4-8", "messages": [{"role":"user","content":"Say the single word: ping"}], "max_tokens": 16}'
--- request 1 -> bedrock/anthropic.claude-opus-4-8 (limit is $0.0001) ---
  ALLOWED, content: 'ping'
--- request 2 -> bedrock/anthropic.claude-opus-4-8 (limit is $0.0001) ---
  ALLOWED, content: 'ping'
--- request 3 -> bedrock/anthropic.claude-opus-4-8 (limit is $0.0001) ---
  ALLOWED, content: 'ping'
  1. curl -X GET "$P/key/info?key=$KEY" -H "Authorization: Bearer sk-lit5894"
{
  "spend": 0.00034,
  "model_max_budget": {"claude-opus-4-8": {"time_period": "18h", "budget_limit": 0.0001}},
  "model_max_budget_usage": {
    "claude-opus-4-8": {"current_spend": 0.0, "budget_limit": 0.0001, "time_period": "18h"}
  }
}

Three requests served at 3.4x the cap, and the usage counter never moved.

B. Same key budget, traffic on anthropic/claude-opus-4-8

  1. Same /key/generate call, same budget
  2. Three times: the same /v1/chat/completions call with "model": "anthropic/claude-opus-4-8"
--- request 1 -> anthropic/claude-opus-4-8 (limit is $0.0001) ---
  ALLOWED, content: 'ping'
--- request 2 -> anthropic/claude-opus-4-8 (limit is $0.0001) ---
  BLOCKED: {"message": "LiteLLM Virtual Key: 1e2a8cd8..., exceeded budget for model=anthropic/claude-opus-4-8", "type": "budget_exceeded", "code": "429"}
--- request 3 -> anthropic/claude-opus-4-8 (limit is $0.0001) ---
  BLOCKED: {"message": "LiteLLM Virtual Key: 1e2a8cd8..., exceeded budget for model=anthropic/claude-opus-4-8", "type": "budget_exceeded", "code": "429"}
  1. curl -X GET "$P/key/info?key=$KEY" -H "Authorization: Bearer sk-lit5894"
{
  "spend": 0.00017,
  "model_max_budget_usage": {
    "claude-opus-4-8": {"current_spend": 0.0, "budget_limit": 0.0001, "time_period": "18h"}
  }
}

This is the sharp one: the key is actively being refused at 429 and the endpoint an operator would build a dashboard on reports zero.

C. User-level budget, monthly window, key carries none

  1. curl -X POST $P/user/new -H "Authorization: Bearer sk-lit5894" -d '{"user_id": "...", "model_max_budget": {"claude-opus-4-8": {"budget_limit": 0.0001, "time_period": "1mo"}}}'
{"user_id": "lit5894-C-before-...", "model_max_budget": {"claude-opus-4-8": {"budget_limit": 0.0001, "time_period": "1mo"}}}
  1. curl -X GET "$P/user/info?user_id=..." -H "Authorization: Bearer sk-lit5894"
{"model_max_budget": {}}

The create call echoed the budget back; nothing was stored.

  1. curl -X POST $P/key/generate -d '{"user_id": "...", "duration": "18h"}', then three times the /v1/chat/completions call with "model": "claude-opus-4-8"
--- request 1 -> claude-opus-4-8 (user limit is $0.0001/1mo) ---
  ALLOWED, content: 'ping'
--- request 2 -> claude-opus-4-8 (user limit is $0.0001/1mo) ---
  ALLOWED, content: 'ping'
--- request 3 -> claude-opus-4-8 (user limit is $0.0001/1mo) ---
  ALLOWED, content: 'ping'
  1. curl -X GET "$P/user/info?user_id=..." -H "Authorization: Bearer sk-lit5894"
{"spend": 0.00051, "model_max_budget": {}, "model_max_budget_usage": null}

After (b05e217)

Legs A to D were captured on 559d0ff. The commits since add the passthrough fix that leg E covers and the dashboard license gate, neither of which touches the /v1/chat/completions path these four legs drive.

Legs E and F were captured on b05e217, which is also the build leg G drives as its unfixed side. The head is 81f64a1, which adds only the pre-upgrade counter carry leg G covers. Every leg above ran against a Redis created after that build, so no pre-upgrade counter exists for the carry to find and their results stand unchanged.

A. Key budget keyed claude-opus-4-8, traffic on bedrock/anthropic.claude-opus-4-8

  1. Same /key/generate call as before
  2. Same three /v1/chat/completions calls for bedrock/anthropic.claude-opus-4-8
--- request 1 -> bedrock/anthropic.claude-opus-4-8 (limit is $0.0001) ---
  ALLOWED, content: 'ping'
--- request 2 -> bedrock/anthropic.claude-opus-4-8 (limit is $0.0001) ---
  BLOCKED: {"message": "LiteLLM Virtual Key: b520ce22..., exceeded budget for model=bedrock/anthropic.claude-opus-4-8", "type": "budget_exceeded", "code": "429"}
--- request 3 -> bedrock/anthropic.claude-opus-4-8 (limit is $0.0001) ---
  BLOCKED: {"message": "LiteLLM Virtual Key: b520ce22..., exceeded budget for model=bedrock/anthropic.claude-opus-4-8", "type": "budget_exceeded", "code": "429"}
  1. Same /key/info call
{
  "spend": 0.00017,
  "model_max_budget": {"claude-opus-4-8": {"time_period": "18h", "budget_limit": 0.0001}},
  "model_max_budget_usage": {
    "claude-opus-4-8": {"current_spend": 0.0002, "budget_limit": 0.0001, "time_period": "18h"}
  }
}

B. Same key budget, traffic on anthropic/claude-opus-4-8

  1. Same /key/generate call as before
  2. Same three /v1/chat/completions calls for anthropic/claude-opus-4-8
--- request 1 -> anthropic/claude-opus-4-8 (limit is $0.0001) ---
  ALLOWED, content: 'ping'
--- request 2 -> anthropic/claude-opus-4-8 (limit is $0.0001) ---
  BLOCKED: {"message": "LiteLLM Virtual Key: 2d0c41f0..., exceeded budget for model=anthropic/claude-opus-4-8", "type": "budget_exceeded", "code": "429"}
--- request 3 -> anthropic/claude-opus-4-8 (limit is $0.0001) ---
  BLOCKED: {"message": "LiteLLM Virtual Key: 2d0c41f0..., exceeded budget for model=anthropic/claude-opus-4-8", "type": "budget_exceeded", "code": "429"}
  1. Same /key/info call
{
  "spend": 0.00017,
  "model_max_budget_usage": {
    "claude-opus-4-8": {"current_spend": 0.0002, "budget_limit": 0.0001, "time_period": "18h"}
  }
}

The reported usage is now the number the refusal is computed from.

C. User-level budget, monthly window, key carries none

  1. Same /user/new call as before
{"user_id": "lit5894-C-after-...", "model_max_budget": {"claude-opus-4-8": {"budget_limit": 0.0001, "time_period": "1mo"}}}
  1. Same /user/info call
{"model_max_budget": {"claude-opus-4-8": {"time_period": "1mo", "budget_limit": 0.0001}}}
  1. Same /key/generate for that user with no per-model budget, then three /v1/chat/completions calls for claude-opus-4-8
--- request 1 -> claude-opus-4-8 (user limit is $0.0001/1mo) ---
  ALLOWED, content: 'ping'
--- request 2 -> claude-opus-4-8 (user limit is $0.0001/1mo) ---
  BLOCKED: {"message": "LiteLLM User: lit5894-C-after-..., exceeded budget for model=claude-opus-4-8", "type": "budget_exceeded", "code": "429"}
--- request 3 -> claude-opus-4-8 (user limit is $0.0001/1mo) ---
  BLOCKED: {"message": "LiteLLM User: lit5894-C-after-..., exceeded budget for model=claude-opus-4-8", "type": "budget_exceeded", "code": "429"}
  1. Same /user/info call
{
  "spend": 0.00017,
  "model_max_budget": {"claude-opus-4-8": {"time_period": "1mo", "budget_limit": 0.0001}},
  "model_max_budget_usage": {
    "claude-opus-4-8": {"current_spend": 0.0002, "budget_limit": 0.0001, "time_period": "1mo"}
  }
}

D. The dashboard control, driven end to end

Before: the admin UI has no per-model budget control at all. The only way to set model_max_budget is curl, and the Budget Fallbacks tooltip tells operators to "Configure per-model budgets in Advanced Settings", which does not exist.

After, driven through the real dashboard served same-origin from the proxy, logged in as admin:

  1. Open http://127.0.0.1:4894/ui/?page=api-keys, click Create New Key, expand Optional Settings, click "+ Add Model Budget"

Per-Model Budgets control with one empty row

  1. Pick claude-opus-4-8, type 0.0001, leave the window on Monthly

The row filled in with a sub-cent budget

  1. Click Create Key, then read the key the form just created
$ curl -s -X GET "$P/key/info?key=$KEY" -H "Authorization: Bearer $M"
{
  "key_alias": "lit5894-ui-demo",
  "model_max_budget": {
    "claude-opus-4-8": {"time_period": "30d", "budget_limit": 0.0001}
  },
  "model_max_budget_usage": {
    "claude-opus-4-8": {"current_spend": 0.0, "budget_limit": 0.0001, "time_period": "30d"}
  }
}
  1. Drive that key, the one the dashboard created, against the model it budgeted
--- request 1 -> claude-opus-4-8 (form said $0.0001 / Monthly) ---
  ALLOWED, content: 'ping'
--- request 2 -> claude-opus-4-8 (form said $0.0001 / Monthly) ---
  BLOCKED: {"message": "LiteLLM Virtual Key: 827fe343..., key_alias: lit5894-ui-demo, exceeded budget for model=claude-opus-4-8", "type": "budget_exceeded", "code": "429"}
  1. Read the usage the dashboard renders back
{"model_max_budget_usage": {"claude-opus-4-8": {"current_spend": 0.0002, "budget_limit": 0.0001, "time_period": "30d"}}}

E. Native passthrough, /anthropic/v1/messages

Same key shape, same real Anthropic traffic, but routed through the native passthrough handler instead of /v1/chat/completions. This leg covers the BUILT-IN provider routes only. A user-defined pass-through is deliberately excluded from both tracking and enforcement, because get_model_from_request resolves no model there: the body is forwarded verbatim, so its model names an upstream model rather than a LiteLLM-managed one. That handler builds its logging metadata from StandardLoggingUserAPIKeyMetadata, which carries no budget field, and never calls add_litellm_data_to_request, so the post-call increment found nothing to increment.

Before (unfixed passthrough):

  1. curl -X POST $P/key/generate -H "Authorization: Bearer sk-lit5894" -d '{"key_alias": "lit5894-passthrough-before", "models": ["anthropic/*"], "model_max_budget": {"claude-opus-4-8": {"budget_limit": 10, "time_period": "30d"}}}'
  2. curl -X POST $P/anthropic/v1/messages -H "Authorization: Bearer $KEY" -H 'anthropic-version: 2023-06-01' -d '{"model": "claude-opus-4-8", "max_tokens": 16, "messages": [{"role": "user", "content": "Reply with the single word: budget"}]}'
{"model": "claude-opus-4-8", "usage": {"input_tokens": 16, "output_tokens": 4}, "content": [{"type": "text", "text": "budget"}]}
  1. curl -X GET "$P/key/info?key=$KEY" -H "Authorization: Bearer sk-lit5894"
spend        : 0.00018
model_max_budget_usage: {
  "claude-opus-4-8": {"current_spend": 0.0, "budget_limit": 10.0, "time_period": "30d"}
}

Real money left the account and the counter the budget is computed from never moved.

After (b05e217), same three calls:

spend        : 0.00018
model_max_budget_usage: {
  "claude-opus-4-8": {"current_spend": 0.0002, "budget_limit": 10.0, "time_period": "30d"}
}

Tracking is only half of it, so the same leg run against a cap smaller than one request, to show the counter is the one that refuses:

  1. curl -X POST $P/key/generate -d '{"key_alias": "lit5894-passthrough-enforce", "models": ["anthropic/*"], "model_max_budget": {"claude-opus-4-8": {"budget_limit": 0.0001, "time_period": "30d"}}}', then the same /anthropic/v1/messages call twice
--- first passthrough request: under the cap, expected to succeed
HTTP 200

--- second passthrough request: the cap is now blown, expected to be refused
{"error":{"message":"LiteLLM Virtual Key: 48dc720c..., key_alias: lit5894-passthrough-enforce, exceeded budget for model=claude-opus-4-8","type":"budget_exceeded","param":null,"code":"429"}}
HTTP 429

Negative control, so the 429 above cannot be read as something else doing the work. Identical key, identical $0.0001 cap, identical two requests, run on the unfixed handler:

key: sk-mjoK... (same $0.0001 cap, unfixed code)

--- first passthrough request
HTTP 200

--- second passthrough request: the cap is blown in reality, but nothing refuses it
HTTP 200

spend        : 0.00018
model_max_budget_usage: {
  "claude-opus-4-8": {"current_spend": 0.0, "budget_limit": 0.0001, "time_period": "30d"}
}

Twice over the cap, both served, counter still zero.

F. A zero-dollar cap, which is the strictest limit an operator can set

budget_limit: 0 means nobody may spend anything on this model. The old check skipped any cap that was falsy or <= 0, so the strictest possible setting behaved as no setting at all. The dashboard editor added here can produce that value, which is how it surfaced.

Before (zero-cap check skipped):

  1. curl -X POST $P/key/generate -H "Authorization: Bearer sk-lit5894" -d '{"key_alias": "lit5894-zerocap-before", "models": ["anthropic/*"], "model_max_budget": {"claude-opus-4-8": {"budget_limit": 0, "time_period": "30d"}}}'
  2. One POST $P/v1/chat/completions for claude-opus-4-8, the first request on the key, with nothing spent yet
{"model": "claude-opus-4-8", "choices": [{"message": {"content": "ping", "role": "assistant"}}], "usage": {"total_tokens": 18}}
  1. curl -X GET "$P/key/info?key=$KEY" -H "Authorization: Bearer sk-lit5894"
{"claude-opus-4-8": {"current_spend": 0.0002, "budget_limit": 0.0, "time_period": "30d"}}

Served, real money spent, and the endpoint then reports the overspend against a cap of 0.0. An operator can watch the limit being exceeded with nothing enforcing it.

After (b05e217), same two calls:

{"error":{"message":"LiteLLM Virtual Key: 6e74482b..., key_alias: lit5894-zerocap-after, exceeded budget for model=claude-opus-4-8","type":"budget_exceeded","param":null,"code":"429"}}
HTTP 429
{"claude-opus-4-8": {"current_spend": 0.0, "budget_limit": 0.0, "time_period": "30d"}}

Refused on the very first request, before any spend, which is what a $0 cap has to mean.

G. A budget window that was already open when the proxy upgraded

Moving the counter key from the request model to the configured budget model orphans the counter an operator's traffic is already being charged to, so an upgrade mid-window would hand that key up to one more full allowance, the whole of it when every request used the prefixed spelling and the prefixed share of it otherwise. Enforcement now adds the pre-upgrade counter in for the remainder of its window.

One Postgres, one Redis, one port, three builds in sequence. Counters are Redis-backed here (general_settings.coordination_redis plus litellm_settings.enable_redis_auth_cache: true), which is what lets them outlive the restart and makes an upgrade observable at all. Two keys are created on the old release and driven identically, so the two builds under test each start from a counter the old release wrote itself rather than from one this capture invented.

  1. On fc3b160fb5, curl -X POST $P/key/generate -H "Authorization: Bearer sk-lit5894m" -d '{"key_alias": "...", "duration": "18h", "model_max_budget": {"claude-opus-4-8": {"budget_limit": 0.0001, "time_period": "18h"}}}', twice, for keys A and B
  2. Twice per key: curl -X POST $P/v1/chat/completions -H "Authorization: Bearer $KEY" -d '{"model": "anthropic/claude-opus-4-8", "messages": [{"role":"user","content":"Say the single word: ping"}], "max_tokens": 16}'
--- key A request 1 -> anthropic/claude-opus-4-8
content: "ping"
HTTP 200
--- key A request 2 -> anthropic/claude-opus-4-8
{"message": "LiteLLM Virtual Key: b7ce0312..., key_alias: lit5894m-1315-A, exceeded budget for model=anthropic/claude-opus-4-8", "type": "budget_exceeded", "code": "429"}
HTTP 429
--- key B request 1 -> anthropic/claude-opus-4-8
content: "ping"
HTTP 200
--- key B request 2 -> anthropic/claude-opus-4-8
{"message": "LiteLLM Virtual Key: 5d9855d7..., key_alias: lit5894m-1315-B, exceeded budget for model=anthropic/claude-opus-4-8", "type": "budget_exceeded", "code": "429"}
HTTP 429

counters this release left in redis:
  virtual_key_spend:5d9855d7...:anthropic/claude-opus-4-8:18h = 0.00017
  virtual_key_spend:b7ce0312...:anthropic/claude-opus-4-8:18h = 0.00017

Both keys are over their cap and being refused, on counters keyed by the model as REQUESTED.

  1. Restart on b05e217b12, this PR before the fix below, against the same Redis and the same Postgres. Same request, key A
--- key A, first request after the upgrade
content: "ping"
HTTP 200

counters now:
  virtual_key_spend:5d9855d7...:anthropic/claude-opus-4-8:18h = 0.00017
  virtual_key_spend:b7ce0312...:anthropic/claude-opus-4-8:18h = 0.00017
  virtual_key_spend:b7ce0312...:claude-opus-4-8:18h = 0.00017

A key that was being refused a minute earlier is served, and the third line is the mechanism: the same window is now being charged to a second counter that started empty.

  1. Restart on the fixed build, same Redis and Postgres. Same request, key B
--- key B, first request after the upgrade
{"message": "LiteLLM Virtual Key: 5d9855d7..., key_alias: lit5894m-1315-B, exceeded budget for model=anthropic/claude-opus-4-8", "type": "budget_exceeded", "code": "429"}
HTTP 429
  1. Control on that same build, so the 429 above cannot be read as the build refusing everything. A new key, the same cap, nothing spent yet
--- key C request 1 (nothing spent yet)
content: "ping"
HTTP 200
--- key C request 2 (cap now blown)
{"message": "LiteLLM Virtual Key: b5cf6c0a..., key_alias: lit5894m-1315-C, exceeded budget for model=anthropic/claude-opus-4-8", "type": "budget_exceeded", "code": "429"}
HTTP 429

Review notes

Correcting a claim I published on this PR earlier. Replying to the cache-key migration finding I wrote that "the old code wrote spend to a key that enforcement never read". That is true for a Bedrock id the previous release matched no budget for, and it is false for the provider-prefixed case the finding actually named: _get_virtual_key_spend_for_model read the request-model key FIRST and only then fell back to the stripped spelling, so a budget on gpt-4 taking traffic for openai/gpt-4 was enforced on virtual_key_spend:<id>:openai/gpt-4:<duration>. Leg G above is that counter being written and enforced by the previous release, then ignored after the upgrade. The finding was right and it is fixed here rather than argued with.

Two bounds worth stating rather than leaving to be discovered. The carry is read-only and stops one budget window after start-up, since a pre-upgrade counter belongs to a window that was already open when this process replaced the one writing it. And /key/info cannot include it: the reporting path knows the configured budget name and not which request spellings were charged against it, so during that one window reported usage can sit below the number enforcement is refusing on. It converges when the pre-upgrade counter expires.

One more bound on the carry, since it is easier to state than to discover. The pre-upgrade counter was written under model_group or model while enforcement reconstructs it from the request model. Those are the same string on the router path and differ on a direct deployment with no model group, where the carry finds nothing and the request is admitted exactly as it is today.

Type

🐛 Bug Fix
🆕 New Feature

Caveats (if any)

  • A budget window open at upgrade keeps its spend and restarts its clock
  • A pre-upgrade counter is honoured for one window, then stops being read
  • /key/info cannot name that counter, so it can lag enforcement for a window
  • The counter key changed to the configured model name
  • Bedrock family matching is gated on the model-cost map
  • Custom-auth deployments gain one cache-first user read
  • Reported budget_limit 200 becoming 0.5 reproduces nowhere
  • The key edit form's 5 pre-existing pointer-event test failures predate this
  • The editor is read-only without a license, matching the write gate
  • /user/update does not gate model_max_budget, unlike /user/new
  • A saved budget is normalised to the budget_limit / time_period spelling
  • An unreachable DB reads as no user budget, pre-existing in get_user_object
  • Spend exactly at a per-model cap is now refused, matching sibling checks
  • Custom auth now skips budget checks for zero-cost models, as JWT already did
  • Custom auth reads the user row on every request, not only budgeted ones
  • Bulk user edit does not offer the editor, since it forwards a fixed field list
  • User-defined pass-through routes are neither tracked nor enforced, as before
  • An unusable model_max_budget entry falls through to the next candidate
  • Compaction enforces the user per-model budget its summary spend charges

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Note

High Risk
Changes auth-time budget enforcement and post-call spend accounting for keys, users, and end users. A mismatch here can over-serve traffic or refuse valid requests.

Overview
Fixes per-model budgets so enforcement, post-call spend, and /key/info / /user/info all use the same counter, keyed on the configured budget model rather than the request spelling.

User-level model_max_budget is now real: auth loads it onto UserAPIKeyAuth, JWT / virtual-key / custom-auth paths enforce it, /user/new persists it, and /user/info reports usage. Compaction summaries and built-in provider passthrough attach the same metadata so those calls cannot skip the cap.

Matching and windows: Bedrock ids resolve to family names via the cost map; $0 caps and spend-at-cap now refuse; budget windows are per model. For one window after upgrade, key/end-user enforcement still adds the old request-model Redis counter so a mid-window deploy does not grant a second allowance. User-defined pass-through routes stay untracked, as before.

Reviewed by Cursor Bugbot for commit 81f64a1. Bugbot is set up for automated code reviews on this repo. Configure here.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR aligns per-model budget enforcement, spend increments, and usage reporting across key, user, compaction, and built-in passthrough paths

  • Uses configured budget-model names consistently while temporarily honoring counters written by the previous release
  • Persists and reports user-level budgets and propagates them through authentication and logging metadata
  • Adds licensed dashboard controls for creating and editing per-model budgets

Confidence Score: 5/5

The PR appears safe to merge

No blocking failure remains

Important Files Changed

Filename Overview
litellm/proxy/auth/user_api_key_auth.py Propagates and enforces user-level per-model budgets across virtual-key, JWT, and custom-auth request paths
litellm/proxy/hooks/model_max_budget_limiter.py Unifies counter ownership under configured budget models and carries pre-upgrade spend for a bounded transition window
litellm/proxy/pass_through_endpoints/pass_through_endpoints.py Preserves budget metadata for built-in provider passthrough routes while excluding user-defined passthrough handlers
litellm/proxy/management_endpoints/internal_user_endpoints.py Persists user per-model budgets and includes current usage in user information responses
ui/litellm-dashboard/src/components/key_team_helpers/ModelMaxBudgetEditor.tsx Adds the shared licensed editor used by key and internal-user budget forms

Reviews (19): Last reviewed commit: "fix(proxy): make per-model budgets track..." | Re-trigger Greptile

Comment thread litellm/proxy/_types.py Outdated
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.29412% with 8 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/proxy/hooks/model_max_budget_limiter.py 96.26% 4 Missing ⚠️
...pass_through/context_management/editors/compact.py 70.00% 3 Missing ⚠️
litellm/proxy/_types.py 66.66% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_model_max_budget_usage (81f64a1) with litellm_internal_staging (65b4ac0)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (e17988f) during the generation of this report, so eb6296e was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@yassin-berriai
yassin-berriai force-pushed the litellm_model_max_budget_usage branch from 5b0c98f to bd41a9b Compare August 20, 2026 23:33
@yassin-berriai
yassin-berriai force-pushed the litellm_model_max_budget_usage branch from bd41a9b to 0b15382 Compare August 20, 2026 23:40
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai both findings were real and are fixed on 0b15382: JWT path now enforces before it returns, plus a call-graph test. Please re-review.

Comment thread litellm/proxy/auth/user_api_key_auth.py Outdated
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai auto-register was right and is fixed on 559d0ff. Mapped-key is not reachable: it sets do_standard_jwt_auth=False and falls through to the virtual-key checks. Please re-review.

Comment thread litellm/proxy/hooks/model_max_budget_limiter.py Outdated
Comment thread litellm/proxy/hooks/model_max_budget_limiter.py Outdated
@veria-ai

veria-ai Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 4 · PR risk: 0/10

@yassin-berriai
yassin-berriai force-pushed the litellm_model_max_budget_usage branch from 559d0ff to 5e362cc Compare August 21, 2026 00:49
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai re-review 5e362cc: native passthrough now attaches the per-model budget metadata, and the dashboard editor is license-gated with unchanged budgets omitted.

@yassin-berriai
yassin-berriai force-pushed the litellm_model_max_budget_usage branch from 5e362cc to a28fd62 Compare August 21, 2026 00:51
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai re-review a28fd62. Only change since the last request: the editor render test moved to the integration tier.

Comment thread litellm/proxy/hooks/model_max_budget_limiter.py Outdated
@yassin-berriai
yassin-berriai force-pushed the litellm_model_max_budget_usage branch from a28fd62 to cfbf88d Compare August 21, 2026 00:59
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai re-review cfbf88d: the budget editor now reads either BudgetConfig spelling and carries through tpm_limit and rpm_limit, so editing one model cannot drop another.

Comment thread litellm/proxy/auth/user_api_key_auth.py
@yassin-berriai
yassin-berriai force-pushed the litellm_model_max_budget_usage branch from cfbf88d to 8607548 Compare August 21, 2026 01:06
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai re-review 8607548. The custom-auth finding is not correct, and failing closed there would be an outage rather than a hardening.

get_user_object(user_id_upsert=False) raises a bare Exception when the user row is merely ABSENT, not only when the read fails (auth_checks.py:2176). A custom-auth deployment that never writes users into the proxy DB hits that on every request, so refusing on it would 4xx all of them.

The virtual-key path already makes the identical call and swallows the identical exception, logging "Unable to get user from db/cache. Setting user_obj to None" (user_api_key_auth.py:1880-1894). Matching it is the existing contract, not a shortcut.

There is also nothing to bypass. The budget being looked up lives on the row that could not be read, so failing closed refuses traffic on the possibility that a budget exists. Every sibling budget on this path, the key budget, the user max_budget and the team budget, reads those same rows with the same tolerance.

Two tests now pin this, with your suggested change as the mutant: test_user_budget_lookup_tolerates_an_unreadable_user and a positive control that the tolerance is not swallowing every result.

Also in this push: /user/info now refreshes model_max_budget in its local copy after a save, and the dashboard editor reads either BudgetConfig spelling while carrying through tpm_limit and rpm_limit.

@yassin-berriai
yassin-berriai force-pushed the litellm_model_max_budget_usage branch from 8607548 to 7ed409a Compare August 21, 2026 01:09
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai re-review 7ed409a. Correcting one sentence of my previous comment: "there is nothing to bypass" holds only for an absent user row, not for an unreachable database, and the difference is worth stating precisely.

get_user_object cannot distinguish them. The absent case raises inside its own try (auth_checks.py:2177) and the handler at :2213 rewrites every exception into the same ValueError("User doesn't exist in db..."), so a connection error, a query timeout and a malformed row all arrive as that one type and message. Tolerating the absent case therefore tolerates an outage too, and a user who does have a per-model budget goes unenforced while the DB is unreachable.

That is a real fail-open. It is pre-existing behaviour of get_user_object, the virtual-key path at user_api_key_auth.py:1880 inherits it identically, and nothing here makes it worse. Separating the two needs a dedicated exception type for the absent case plus a change to both auth paths, which is outside a per-model-budget PR.

So the rest of the original finding still does not hold: failing closed would 4xx every custom-auth request whose user has no row, which is the normal state for deployments that do not write users to the proxy DB. Three tests now pin all of it, the third asserting the outage case falls through unenforced so the limitation is recorded as known rather than accidental.

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread litellm/proxy/auth/user_api_key_auth.py Outdated
Comment thread litellm/proxy/hooks/model_max_budget_limiter.py
@yassin-berriai
yassin-berriai force-pushed the litellm_model_max_budget_usage branch from 8099fc1 to 0fb82e9 Compare August 21, 2026 03:16
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

bugbot run

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai re-review 0fb82e9: the per-model budget editor is no longer offered in bulk user edit, where the payload forwards a fixed field list and would have discarded it silently.

Comment thread litellm/proxy/pass_through_endpoints/pass_through_endpoints.py Outdated
@yassin-berriai
yassin-berriai force-pushed the litellm_model_max_budget_usage branch from 0fb82e9 to 8136f01 Compare August 21, 2026 03:30
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

bugbot run

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai re-review 8136f01: the pass-through budget metadata is now attached only on the built-in provider routes, since get_model_from_request deliberately resolves no model for a user-defined pass-through and enforcement is skipped there.

Comment thread litellm/proxy/pass_through_endpoints/pass_through_endpoints.py

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 8136f01. Configure here.

@yassin-berriai
yassin-berriai force-pushed the litellm_model_max_budget_usage branch from 8136f01 to 3e6f6de Compare August 21, 2026 03:39
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai re-review 3e6f6de. The premise here is right and the conclusion does not follow, so the built-in provider routes are unaffected.

Right: llm_passthrough_endpoints.py does call create_pass_through_route, and that factory sets LITELLM_PASS_THROUGH_ENDPOINT_MARKER on what it returns (pass_through_endpoints.py:1859).

Not right: that product is never registered as a route. It is constructed inside the decorated handler at request time and awaited immediately (llm_passthrough_endpoints.py:176-182), so it never reaches FastAPI's routing table. The marker check reads request.scope["endpoint"], which FastAPI sets to the handler that was DISPATCHED, and for /anthropic/... that is anthropic_proxy_route, which carries no marker. request_dispatched_to_pass_through_endpoint documents exactly this: built-in provider routes are separate handlers and keep model enforcement.

Two independent confirmations. The proof-of-fix leg in the PR description drives real traffic through /anthropic/v1/messages and gets a 429 budget_exceeded on the second request, which could not happen if those routes were classified as user-defined. And the new tests assert it directly: six parametrised cases that the built-in handlers are unmarked, plus a positive control that the check discriminates, since the factory's product returns True where anthropic_proxy_route returns False. Without that control, "not marked" would also pass against a check that always returned False.

test_builtin_provider_routes_do_not_carry_the_user_defined_marker and test_the_marker_check_distinguishes_the_two_route_kinds fail if a built-in provider route ever becomes a registered pass-through product, which is the regression this finding describes.

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread litellm/proxy/hooks/model_max_budget_limiter.py
@yassin-berriai
yassin-berriai force-pushed the litellm_model_max_budget_usage branch from 3e6f6de to b05e217 Compare August 21, 2026 03:48
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

On b05e217. The general rule in this finding is right and it does not hold for this model, so the documented spelling is not bypassed. It did point at a real fragility though, which is now pinned.

Measured against the installed Pydantic rather than argued:

BudgetConfig(**{'budget_limit': 5, 'time_period': '1d'})        -> max_budget=5.0  budget_duration=1d
BudgetConfig.model_validate({'budget_limit': 5, ...})           -> max_budget=5.0  budget_duration=1d
BudgetConfig.model_validate({'bogus_limit': 5, ...})            -> max_budget=None budget_duration=1d
resolve_model_budget('gpt-4', {'gpt-4': {'budget_limit': 5, 'time_period': '1d'}})
                                                                -> gpt-4 max_budget=5.0 budget_duration=1d

The third line is the control: an unrecognised key leaves max_budget unset, so the second line is the alias mapping being applied rather than the model accepting anything. Two further confirmations: the unit tests in this file use the budget_limit / time_period spelling throughout and pass, and the proof-of-fix leg in the PR description drives real traffic with that spelling and gets a 429 plus non-zero reported usage.

Where you are right is that nothing in this repository guarantees it. model_validate bypassing a custom __init__ is the documented Pydantic v2 behaviour, so this working is a property of the installed version, and an upgrade could start silently discarding every budget written in the documented spelling. That is a quiet failure worth a guard, so test_documented_budget_spelling_survives_model_validate and test_resolution_accepts_both_documented_spellings now assert it, with the control above included. Disabling the mapping in BudgetConfig.__init__ makes both fail.

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

bugbot run

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai re-review b05e217: adds regression tests pinning that BudgetConfig's budget_limit and time_period aliases survive model_validate, with a control, so a Pydantic upgrade that stopped applying them fails loudly instead of silently dropping every budget in the documented spelling.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit b05e217. Configure here.

…he same counter

Per-model budgets were three separate things pretending to be one. The
enforcement check, the post-call increment and the info endpoints each derived
their own cache key, so a budget could refuse traffic at 429 while /key/info
reported zero usage, and a Bedrock model id never matched a budget keyed on the
bare family name. /user/new echoed a model_max_budget back and stored an empty
dict, and nothing enforced a user-scoped per-model budget at all.

One owner now builds the counter key from the configured budget model, and
enforcement, the increment and the info endpoints all read it. Bedrock ids
resolve through the model-cost map. Auth carries the user's budget onto the
token on every branch that reaches the spend hook, including JWT and
auto-registration. Native passthrough attaches the three budget metadata keys
its StandardLoggingUserAPIKeyMetadata does not carry, so /anthropic/... and
/bedrock/... traffic is counted and capped like /v1/chat/completions.

The dashboard gains the per-model budget editor it never had, on the key create,
key edit and internal-user edit forms. It is read-only without an enterprise
license, matching the write gate the proxy already enforces, and an untouched
budget is left out of an update so an unrelated edit cannot trip that gate.

The editor hydrates from either BudgetConfig spelling, since model_max_budget is
a plain dict that the proxy stores exactly as the client sent it, and it carries
through the fields it does not model. Without both, editing one model would drop
another model row entirely and silently discard its tpm_limit and rpm_limit.

/user/info refreshes its local copy of the user field by field after a save, so
model_max_budget joins that list. Left out, a saved cap read back as the old one
when the form was reopened, and clearing the row to recover would then wipe the
value that had actually persisted.

A zero-dollar cap is the strictest limit expressible, not the absence of one,
so it is enforced rather than skipped on falsiness, spend exactly at the cap is
refused the way every sibling budget check already refuses it, and a counter
that was never written reads as zero spend rather than as unknown. The usage
endpoints read every counter in one batched lookup, so a large model_max_budget
cannot fan out into one concurrent cache call per configured model.

Every auth path honours the same zero-cost skip flag, so none of them can refuse
a free request that another serves. The custom-auth helper gains the flag it
never had, which also changes its pre-existing key and end-user checks.

The compaction summary gate checks the user scope alongside the key and end-user
ones. This file propagates all three budgets into the summary subrequest, so
enforcing only two let compaction increment a counter it could not be refused by.

Custom auth attaches the user's budget to the token unconditionally, since the
post-call spend hook reads it there: gating the attach on the same condition as
enforcement left the counter uncharged whenever the request was not itself
enforceable. An entry that will not validate is skipped rather than raised on,
so one malformed scope cannot abort every other scope's increment or turn a
config typo into a 500.

The edit forms re-seed the budget editor when a different key or user is loaded.
Its rows are seeded once and cannot re-read their own value prop, so without
this a save wrote the previously loaded record's budgets onto the current one.

Only the built-in provider pass-through routes carry the budget metadata.
get_model_from_request deliberately resolves no model for a user-defined
pass-through, since its body is forwarded verbatim and names an upstream model,
so attaching there would charge a counter nothing on that route can refuse.
@yassin-berriai
yassin-berriai force-pushed the litellm_model_max_budget_usage branch from b05e217 to 81f64a1 Compare August 21, 2026 06:36
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@veria-ai please re-review 81f64a1. Your cache-key migration finding was right: the pre-upgrade counter is now carried, with a live three-build proof.

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai re-review 81f64a1: a per-model counter written by the previous release is now honoured for the rest of its window.

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 81f64a1. Configure here.

@tin-berri tin-berri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed via background agent given size (3654/1039 across 35 files) and billing-enforcement stakes. Verdict: the core claims all check out against the actual diff, not just the PR description.

  • One counter key: resolve_model_budget()/model_budget_spend_cache_key() is the sole matcher shared by enforcement (_is_entity_within_model_budget), the post-call increment, and the reporting endpoints (/key/info, /user/info now read the same dual_cache the limiter writes to) — this is the exact fix for "blocked at 429, reports zero usage."
  • Zero-cap bug: genuinely fixed — the comparison flips to >= (spend-at-cap now refused) and the zero-cap short-circuit is corrected, with a direct test (test_a_zero_dollar_cap_blocks_the_model) asserting BudgetExceededError.
  • User-level enforcement: wired into all three auth surfaces (virtual-key, JWT/mapped-key, custom-auth) via the same resolver, each with a BudgetExceededError-raising test — this was a real, previously-unenforced gap, now closed.
  • Bedrock resolution: correctly gates on litellm_provider.startswith("bedrock") before splitting a dotted id (avoiding false positives on e.g. azure/gpt-4.1), and delegates the actual base-model resolution to the existing, already-battle-tested get_bedrock_base_model() rather than reinventing cross-region/ARN/provisioned-throughput handling.
  • Migration compat: real bounded shim (_legacy_request_model_spend_cache_key) that carries a pre-upgrade counter for one process-lifetime window, not a silent reset — with the two real limitations (reporting lag, model-string match requirement) honestly documented and tested rather than hidden.
  • Passthrough spend: genuinely attached, gated on the same predicate used for tracking so tracking/enforcement can't disagree about which routes qualify, plus an explicit test that it can't be forged via request body.
  • Several bot-flagged "P1"/"High" findings were spot-checked against actual head code (not just the labels) and are either already fixed or false positives (e.g. Cursor's pydantic alias-bypass claim doesn't reproduce against pydantic 2.13.4's actual model_validate behavior).

One non-blocking loose end: the per-model budget editor in user_edit_view.tsx seeds its state via useSeededState(user_id, ...), which only re-seeds on a user_id change — if userData refreshes with real budgets after an initial incomplete-data mount, the editor could show stale/empty rows and a save could clear existing budgets. UI-only, gated behind premium license + admin role, not a backend billing-bypass — worth a quick look, not a blocker.

CI green (72/72). Approved.

@yassin-berriai
yassin-berriai merged commit 7da34e8 into litellm_internal_staging Aug 21, 2026
75 checks passed
@yassin-berriai
yassin-berriai deleted the litellm_model_max_budget_usage branch August 21, 2026 16:47
tin-berri added a commit that referenced this pull request Aug 21, 2026
Two whole-tree test lints are red on litellm_internal_staging, which
blocks the lint job on every PR into it.

test_user_api_key_auth.py used pytest.raises(Exception) with no match=.
B017 forbids that (enforced since #37731): any Exception subtype,
including one from an unrelated regression, satisfies the assert and
reads as a pass. Narrowed with match=r"(?i)budget", which preserves the
original `assert "budget" in str(exc.value).lower()` it replaces.

test_unit_test_max_model_budget_limiter.py wrapped an if/else with two
different awaited calls inside pytest.raises(). PT012 forbids that
(enforced since #37748): the block must hold a single simple statement,
so a coroutine built in the wrong branch cannot silently never run.
The coroutine is now built outside the block and awaited inside it.

Both violations landed in #37736, one day before ruff-tests.toml began
enforcing these rules whole-tree, so no delta-vs-base gate caught them.

Verified: `ruff check --config ruff-tests.toml tests` is clean, both
tests pass, and each still fails under an injected regression.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants