Skip to content

feat(key management): show every budget that applies to a key - #37570

Open
ryan-crabbe-berri wants to merge 37 commits into
litellm_internal_stagingfrom
litellm_key_budgets_endpoint
Open

feat(key management): show every budget that applies to a key#37570
ryan-crabbe-berri wants to merge 37 commits into
litellm_internal_stagingfrom
litellm_key_budgets_endpoint

Conversation

@ryan-crabbe-berri

@ryan-crabbe-berri ryan-crabbe-berri commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • A budget 429 names no entity, so nobody knows what blocked them
  • Eleven budget scopes can apply to one key
  • Key, team and user can all read unlimited while something else blocks
  • An exhausted team member budget made the key unreadable, preventing diagnosis

How it solves it:

  • New GET /key/{key_id}/budgets lists every budget applying to a key
  • Each row carries live spend, reset time and the real blocking threshold
  • Unconfigured scopes still get a row, so they can be ruled out
  • A scope the server cannot read reports unknown, never unlimited
  • The proxy-wide limit and spend are reported only to proxy admins
  • Only budgets that can actually reject a request are reported
  • Reading a team mate's key needs the team's /key/{key_id}/budgets permission
  • A Budgets tab leads with the verdict, then bullet graphs, then the table

User Flow

Before: a developer whose key, team and user all read unlimited gets a 429 naming nothing, and cannot find the budget responsible

  1. They send POST https://litellm-domain/v1/chat/completions with their key
  2. They get a 429 reading Budget has been exceeded! Current cost: 0.008835, Max budget: 0.003, naming no team, user or organization
  3. They send GET https://litellm-domain/key/info to inspect the key, and get that same 429 back instead of the key, so a blocked key cannot even read itself
  4. They open https://litellm-domain/ui/?page=api-keys, click the key, and see Budget: Unlimited
  5. They open the team page, then the organization page, then the team member list, checking each for a budget of its own
  6. Nothing they can reach explains the number in step 2, so they ask an administrator to read the database

After: the same 429 is traceable to one named budget in a single request

  1. They send POST https://litellm-domain/v1/chat/completions with their key
  2. They get the same 429, now naming the user and team it applies to
  3. They send GET https://litellm-domain/key/info and get the key back, because an exhausted budget no longer blocks reading it
  4. They send GET https://litellm-domain/key/budgets and get one row per budget that applies, each naming its scope, the entity holding it, its limit, its live spend, when it resets, and the threshold at which it stops requests
  5. Exactly one row reads exceeded, naming the entity responsible, and the sibling rows read ok so the others are ruled out rather than guessed at
  6. They open https://litellm-domain/ui/?page=api-keys, click the key and open the Budgets tab, which shows the same list with the blocking row first

Relevant issues

Linear ticket

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • 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, run once against the same database on both sides. An org at $0.05, two teams under it at $0.05, an internal user at $0.05, a $0.048 team member budget, and a key at $0.04 with a $0.045 per-model cap on gpt-5.5:

curl -s $PROXY/organization/new -H "Authorization: Bearer $ADMIN_KEY" -H 'Content-Type: application/json' \
  -d '{"organization_alias":"kb-qa-org","max_budget":0.05,"budget_duration":"30d"}'
curl -s $PROXY/team/new     ... -d '{"team_alias":"kb-qa-team","organization_id":"24f1e4c3-...","max_budget":0.05,"budget_duration":"30d"}'
curl -s $PROXY/user/new     ... -d '{"user_email":"kb-qa-user@example.com","user_role":"internal_user","max_budget":0.05,"budget_duration":"30d"}'
curl -s $PROXY/team/member_add ... -d '{"team_id":"ab7c0a92-...","member":{"role":"user","user_id":"b75b46c2-..."},"max_budget_in_team":0.048}'
curl -s $PROXY/key/generate ... -d '{"key_alias":"kb-qa-key-2","team_id":"ab7c0a92-...","user_id":"b75b46c2-...","max_budget":0.04,"budget_duration":"30d","models":["gpt-5.5"],"model_max_budget":{"gpt-5.5":{"budget_limit":0.045,"time_period":"1d"}}}'

Real OpenAI gpt-5.5 traffic throughout, roughly $0.06 of live spend. Key values are redacted; the path parameter is a key hash.

Before (59c7e7a)

Case 1: find the budget closest to blocking

  1. Ask for the key's budgets as an admin:
$ curl -s -w "\nHTTP %{http_code}\n" "$PROXY/key/5107d67e.../budgets" -H "Authorization: Bearer $ADMIN_KEY"
{"detail":"Not Found"}
HTTP 404
  1. Ask for your own key's budgets:
$ curl -s -w "\nHTTP %{http_code}\n" "$PROXY/key/budgets" -H "Authorization: Bearer $KEY"
{"detail":"Not Found"}
HTTP 404
  1. There is no way to enumerate what applies, so the next step is reading the database by hand

Case 2: an exhausted team member budget blocks reads

  1. Send a completion on a key whose team member budget is spent:
$ curl -s -w "\nHTTP %{http_code}\n" "$PROXY/v1/chat/completions" -H "Authorization: Bearer $MEMBER_KEY" \
    -H 'Content-Type: application/json' -d '{"model":"gpt-5.5","max_tokens":50,"messages":[{"role":"user","content":"ping"}]}'
{"error":{"message":"Budget has been exceeded! Current cost: 0.008835, Max budget: 0.003","type":"budget_exceeded","param":null,"code":"429"}}
HTTP 429
  1. Ask that key to describe itself:
$ curl -s -w "\nHTTP %{http_code}\n" "$PROXY/key/info" -H "Authorization: Bearer $MEMBER_KEY"
{"error":{"message":"Budget has been exceeded! Current cost: 0.008835, Max budget: 0.003","type":"budget_exceeded","param":null,"code":"429"}}
HTTP 429
  1. The 429 names no entity and the key cannot be inspected while blocked

After (2d962c4)

Case 1: find the budget closest to blocking

  1. Send a real completion through the key:
$ curl -s $PROXY/v1/chat/completions -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
    -d '{"model":"gpt-5.5","max_tokens":400,"messages":[{"role":"user","content":"Explain in two short paragraphs what a virtual key is in an LLM gateway."}]}' \
  | jq '{id, model, usage: {prompt_tokens: .usage.prompt_tokens, completion_tokens: .usage.completion_tokens}}'
{
  "id": "chatcmpl-EEmuDIdQ4QDHboBjcNfX1O1ZOFj1T",
  "model": "gpt-5.5",
  "usage": { "prompt_tokens": 22, "completion_tokens": 153 }
}
  1. List every budget that can block that key:
$ curl -s "$PROXY/key/5107d67e.../budgets" -H "Authorization: Bearer $ADMIN_KEY" \
  | jq -r '["SCOPE","ENTITY","ENFORCE","MAX","SPEND","REMAINING","CMP","STATE","STATUS"],
           (.budgets[] | [.scope,(.entity_label // "-"),.enforcement,(.max_budget|tostring),(.spend|tostring),(.remaining|tostring),.comparison,.spend_state,.status]) | @tsv' | column -t -s $'\t'

SCOPE         ENTITY                  ENFORCE  MAX    SPEND     REMAINING  CMP  STATE  STATUS
proxy         -                       hard     null   0.0       null       >    live   unlimited
key           kb-qa-key-2             hard     0.04   0.021045  0.018955   >=   live   ok
team          kb-qa-team              hard     0.05   0.021045  0.028955   >=   live   ok
team_member   kb-qa-team              hard     0.048  0.021045  0.026955   >=   live   ok
user          kb-qa-user@example.com  hard     null   0.021045  null       >=   live   unlimited
organization  kb-qa-org               hard     0.05   0.021045  0.028955   >=   live   ok

The spend is the live counter, not the stored column: /key/info read 0.005415 at the same moment this read 0.008985. Note also that proxy blocks at > while every other hard scope here blocks at >=.

  1. Have a sibling team under the same org, invisible to this key's owner, spend against the shared org budget:
$ for i in 1 2 3 4 5; do curl -s $PROXY/v1/chat/completions -H "Authorization: Bearer $SIBLING_KEY" \
    -H 'Content-Type: application/json' \
    -d '{"model":"gpt-5.5","max_tokens":400,"messages":[{"role":"user","content":"Write eight sentences about ocean currents and upwelling."}]}' \
    | jq -r '"\(.id) completion_tokens=\(.usage.completion_tokens)"'; done
chatcmpl-EEmuYR2wk2Bg2jateHkUSLCg7tImF completion_tokens=98
chatcmpl-EEmuaYnkO0ov8E2YCHmwnVL4BWIe4 completion_tokens=71
chatcmpl-EEmutDXiW00C3w3jvHtl9rnh53lyo completion_tokens=183
chatcmpl-EEmuxpCkdiAhECEiEdAw6lO0kq8Gf completion_tokens=204
chatcmpl-EEmv1q9jtgMjYqAuAcwEKzIS1U6cB completion_tokens=200
  1. Ask the same question again. Nothing about the key, its team or its user changed, and the answer did:
$ curl -s "$PROXY/key/5107d67e.../budgets" -H "Authorization: Bearer $ADMIN_KEY" \
  | jq -r '[.budgets[] | select(.remaining != null)] | min_by(.remaining)
           | "closest to blocking: \(.scope) (\(.entity_label)) remaining=\(.remaining) of \(.max_budget), source=\(.source)"'
closest to blocking: organization (kb-qa-org) remaining=0.005889999999999999 of 0.05, source=budget_table:5105b573-1e84-4463-99d7-d776b0ac61da

The key still has $0.019 of its own headroom. The limit about to deny it is configured on none of the key, the team or the user.

  1. Read the caveats the rows carry:
$ curl -s "$PROXY/key/5107d67e.../budgets" -H "Authorization: Bearer $ADMIN_KEY" \
  | jq -r '.budgets[] | select(.notes|length>0) | "\(.scope)/\(.enforcement): [\(.notes[0].severity)] \(.notes[0].code)"'
team/hard:      [info]    reservation_blocks_at_limit
user/hard:      [info]    user_budget_not_applied_to_team_key

The last row reports max_budget: null even though that user carries $0.05 in the database, because a team key does not inherit a personal budget unless apply_user_budget_to_team_keys is on.

Case 2: an exhausted team member budget no longer blocks reads

  1. The completion is still correctly refused, and now names the entity:
$ curl -s -w "\nHTTP %{http_code}\n" "$PROXY/v1/chat/completions" -H "Authorization: Bearer $MEMBER_KEY" \
    -H 'Content-Type: application/json' -d '{"model":"gpt-5.5","max_tokens":50,"messages":[{"role":"user","content":"ping"}]}'
{"error":{"message":"Budget has been exceeded! User=89ac6300-... in Team=22ae14dd-... Current cost: 0.008835, Max budget: 0.003","type":"budget_exceeded","param":null,"code":"429"}}
HTTP 429
  1. The same key can now read itself, where before it got a 429:
$ curl -s -w "\nHTTP %{http_code}\n" "$PROXY/key/info" -H "Authorization: Bearer $MEMBER_KEY" \
  | jq -c '{key_alias:.info.key_alias, team_id:.info.team_id, spend:.info.spend}'
{"key_alias":"kb-qa-member-key-2","team_id":"22ae14dd-c85b-4421-aa56-82135900aafe","spend":0.008835}
HTTP 200
  1. And it can name what stopped it:
$ curl -s "$PROXY/key/budgets" -H "Authorization: Bearer $MEMBER_KEY" | jq '.budgets[] | select(.status=="exceeded")'
{
  "scope": "team_member", "entity_type": "team_member",
  "entity_id": "89ac6300-...:22ae14dd-...", "entity_label": "kb-qa-member-team",
  "enforcement": "hard", "max_budget": 0.003, "spend": 0.008835, "spend_state": "live",
  "remaining": -0.005835000000000001, "comparison": ">=",
  "source": "budget_table:677ef328-...", "status": "exceeded"
}

The team row on that same key reads max 5.0, remaining 4.99, ok, so the team is ruled out rather than left to guess.

  1. A plaintext key in the path is refused, which is why the path takes a hash:
$ curl -s -w "\nHTTP %{http_code}\n" "$PROXY/key/sk-.../budgets" -H "Authorization: Bearer $ADMIN_KEY"
{"error":{"message":"Pass the key's hash in the path, not the key itself. A URL path is recorded by access logs, tracing spans and error-logging callbacks, so a key placed there does not stay secret. Call GET /key/budgets with the key in the Authorization header to inspect your own key.","code":"400"}}
HTTP 400

Case 3: a scope that cannot be read is reported, not dropped

A key whose team row was deleted underneath it. Every lookup in the resolver used to degrade a failure to "not configured", so this key reported no team budget at all rather than an unreadable one:

$ curl -s "$PROXY/key/$ORPHAN_KEY_HASH/budgets" -H "Authorization: Bearer $ADMIN_KEY" \
  | jq -c '.budgets[] | select(.status=="unknown") | {scope, entity_id, max_budget, spend, spend_state, status, note: .notes[0].code}'
{"scope":"team","entity_id":"cdd13c9d-...","max_budget":null,"spend":null,"spend_state":"unavailable","status":"unknown","note":"entity_unavailable"}
{"scope":"team_window","entity_id":"cdd13c9d-...","max_budget":null,"spend":null,"spend_state":"unavailable","status":"unknown","note":"entity_unavailable"}
{"scope":"organization","entity_id":null,"max_budget":null,"spend":null,"spend_state":"unavailable","status":"unknown","note":"entity_unavailable"}

The organization row carries no entity id because the organization is inherited from the team, and the team is the thing that could not be read.

Budgets tab

Every budget on one key, worst first. The organization is the row closest to its limit here, and it is configured on neither the key, its team nor its user:

Budgets tab listing every scope on one key

The key from case 2, whose 429 named nothing. The team member row is the only blocker, while the key, team and user rows it sits next to read unlimited or within budget:

Budgets tab with the team member budget blocking

The key from case 3. The three scopes behind the unreadable team sort to the top as Unknown, so nobody rules them out:

Budgets tab with three scopes reported as unknown

Type

🆕 New Feature
🐛 Bug Fix

Caveats (if any)

  • Management routes no longer 429 on an exhausted team member budget
  • Team member budgets now block at the limit, not just past it
  • The path form rejects a plaintext key and requires the hash
  • /key/ route values are redacted in failure logs and spans
  • Project rows report a budget that cannot currently trip
  • Soft budgets and per-model budgets are deliberately not reported, since neither
    can reject a request and the per-model scopes fanned out over every model on the
    router rather than the key's own
  • A team member reading another member's key now needs their team to grant
    /key/{key_id}/budgets; proxy admins, the calling key and keys the caller owns
    are unaffected
  • The proxy row reports restricted with no numbers to anyone but a proxy admin,
    since its limit and spend cover the whole deployment rather than the key
  • status gained an unknown value for scopes the server could not read
  • The screenshots below predate the verdict line and bullet graphs, so they show
    the table on its own

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

Renders GET /key/{key_id}/budgets as a table of every budget that can gate the
key, so a 429 that names no entity can be traced to a row without reading auth
source. Rows sort blocking-first, and a soft budget that is over reads as
"Exceeded (alert only)" against "Blocks requests", so an alert can never be
mistaken for the thing that rejected the request. Scopes with nothing configured
still get a row, rendered "Unlimited" rather than $0, which is what lets someone
rule out the org and the team without clicking into them.

The panel is deliberately not keepMounted, so the request is lazy and its cells
cannot collide with the keepMounted Overview panel.

InheritedBudgetHint keeps its client-side team/org guess on the Overview card and
the keys list, where no tab is in reach, but its tooltip now says it is not the
full list and points at this tab, so the two cannot read as competing answers.
A BudgetExceededError names one entity, so a caller who gets a 429 still has to
read auth source to work out which of the key, its windows, its per-model caps,
its team, their membership in that team, the owning user, org, project, the
key's tags, the end user or the proxy-wide limit produced it. This returns all
of them in one call, with the live spend and reset schedule of each, including
the scopes that are left unconfigured so they can be ruled out without opening
every object.

GET /key/budgets reports the calling key. Both routes reuse
_can_user_query_key_info, so reading another key's budgets needs the same rights
as reading its info, and 404 on an unknown key matches /key/info.

The report has to agree with enforcement or it is worse than nothing, so the
resolver consumes the same UserAPIKeyAuth get_key_object hands the auth path,
reads spend through get_current_spend, and shares the limit resolution with the
checks: counter key strings now come from one spend_counter_keys module, and the
team-member, personal-budget-on-team-key and budget-org-id rules were extracted
out of auth_checks for both callers. Each row carries the operator its check
actually uses, since they differ per scope, plus a note where a budget cannot
behave the way its numbers suggest.
…ent routes

The team member budget check ran twice. common_checks calls
_check_team_member_budget behind skip_all_budget_checks, which correctly leaves
non-LLM routes alone, while a second inline copy in _user_api_key_auth_builder
was gated only on the zero-cost-model check and so fired on every route. A
member who had spent their in-team budget got a 429 from /key/info and could no
longer see which budget had stopped them.

The surviving check is a superset of the deleted one: it accepts a per-member
max_budget of 0 as an explicit disable where the inline copy required > 0, it
compares with >= where the inline copy used >, and it adds the team-level
team_member_budget_id default the inline copy never read. common_checks runs
unconditionally for every authenticated request, so nothing stops being
enforced on LLM routes.

Behaviour change: management and UI routes are no longer blocked by an
exhausted team member budget. LLM routes still are.
entity_type was declared str, so the generated client saw a bare string
even though the resolver already carried a Litellm_EntityType and only
called .value at the boundary. Declaring the enum keeps the wire format
identical and lets a caller match a BudgetExceededError's entity against
a row without comparing loose strings.

key is populated on every 200, since a request that resolves no key 404s
before the response is built, so declaring it optional understated the
contract.
Picks up entity_type as a $ref to Litellm_EntityType and the now-required key
field on KeyBudgetsResponse.
Scopes disagree on whether hitting the limit exactly is over it. team_member
enforces >= so 50 of 50 is already denied, while team enforces > so 300 of 300
still passes. The table rendered the numbers and the server's status but not the
operator, so two rows could show identical spend and limit with opposite
statuses and nothing on screen explaining why.

Each row with a limit now states its own threshold, "Blocks at >= $300.00"
against "Blocks at > $300.00", and a soft row says "Alerts at" so the wording
never promises a block it cannot make.
is_info_route compared the request path against info_routes with a bare `in`, so an entry
carrying a path parameter could never match: the incoming route holds a resolved id, not the
`{key_id}` template. /key/{key_id}/budgets was therefore refused for the view-only, team and
customer roles that /key/info serves, and its info_routes entry matched nothing at all.

Route it through check_route_access, the way is_management_route already does. It is the only
templated entry in the list today, so no other route changes reachability.
Four ways the report disagreed with enforcement.

The comparison came from auth_checks alone, but reservation runs first and refuses once spend
has reached the cap, so team, tag and end_user really block at >= while the row claimed >. A
team at 300 of 300 read "ok" next to a member at 50 of 50 reading "exceeded", with nothing on
the row to explain the difference. Every scope reservation covers now reports >=, status
follows that same operator, and a row whose operator was tightened says so. Scopes reservation
does not cover keep their read-time operator, and so does everything when
disable_budget_reservation is set.

Per-model spend is counted under the request model, but the report read the model_max_budget
key. A cap on "gpt-4o" with callers sending "openai/gpt-4o" enforced against a warm counter
while the row showed no spend at all and blamed a cold cache. Probe every model that routes to
the cap and report the highest, since each counter is compared against the cap on its own.
get_request_model_budget_key now owns that matching so enforcement and the report share it.

A single malformed budget_limits or model_max_budget entry dropped every window or model in
the scope, hiding budgets that were still being enforced. Bad entries are skipped one at a
time now.

The end user cap came from the row, but reservation reads the request token's first. Mirror
that precedence, and say plainly when a custom auth callable could be setting one out of view.
end_user_id was free-form and the lookup was a global find_unique with nothing tying the end
user to the key, its team or the caller. Any valid key could read any end user's alias, budget,
live spend and reset times, and a null entity_label told the caller whether an id existed at
all. /key/budgets made that reachable for every role, since a caller trivially satisfies the
key check by inspecting their own key.

End users are a proxy-global namespace, LiteLLM_EndUserTable carries no team or organization
column, so there is nothing to scope a non-admin against. Gate the parameter on proxy admin and
admin viewer, matching /customer/info and /customer/list. The gate is on the parameter, not the
route, so a non-admin reading their own key's budgets is unaffected.
…rator

Regenerates schema.d.ts for the end-user docstring change, which the types sync
gate needs.

Notes now carry two clauses, e.g. "alert only, never blocks; compared against
recorded spend rather than the live counter", and the scope cell truncated them
to one clipped line behind a native tooltip. They are the most load-bearing text
in the table, so they wrap now and the column is wider to suit.

The threshold helper always read `comparison` per row, but its doc comment and
the fixtures around it asserted that team enforces ">", which is no longer true:
reservation tightens team, tag and end_user to ">=", and disabling reservation
relaxes them back. Nothing about the rendering changes, but the tests now pin
that one scope can render either operator instead of teaching a wrong default.
The endpoint's longest reachable note is 276 characters over three clauses,
on an end_user row where budget reservation tightened the operator and a
custom auth callable is configured. Assert the whole string renders and that
the note carries no clipping utility, so re-adding truncate fails the test
rather than silently hiding a caveat.
SpendBudgetCell coerces a null spend to 0 and draws an empty meter, so a
budget whose live counter could not be read rendered as untouched headroom.
The key budgets table now branches before that: an unreadable spend reads
"Unknown" against its limit with no meter, which is the difference between
"we could not tell you" and "there is nothing to tell".
… its own row

`note` shipped as one prose string, so a client had to substring-match it to act
on anything, and two caveats arrived joined by "; " with no way to render them as
the list they were. Each caveat is now a `KeyBudgetNote` with a stable `code` to
branch on, an `info`/`warning` severity, and `text` that stays free to reword, and
`notes` is always a list rather than a nullable string.

Severity is the difference between a row that cannot trip, like a project budget
whose spend is never incremented, and one that needs reading, like a rolling window
or a scope the reservation layer already blocks at the limit.

The absence of a spend number was also being smuggled through the same field. It is
data quality, not a caveat, so it moved to `spend_state`: `live`, `no_counter` for a
per-model budget whose cache-only counter does not exist yet, and `unavailable` for
a read that failed. A failed read no longer renders as untouched headroom.

The endpoint has not shipped, so this costs nothing today and would be a breaking
change tomorrow.
Each caveat now arrives as its own note with a stable code, so the table renders
one line per caveat at its own severity instead of one prose blob, and decides
whether a row is dead by code rather than by matching wording.

A row that structurally cannot trip, like a project budget whose spend is never
incremented, now says so and sorts below every row that can, since it is never
the answer to which budget stopped a request.

spend_state replaces inferring absence from a null spend. A no-counter-yet zero
keeps its $0.00 and its meter because it is genuinely zero, while a failed read
still shows Unknown with no meter. Any state this build predates is treated as
unreadable rather than drawn as a confident number.

CODE_KILLS_ROW is exhaustive over the code union, so a caveat added server-side
fails the build until it is classified, and severity is the runtime fallback for
a code that arrives from a newer server than this bundle.
…through the URL

Round-2 review fixes on the budgets endpoint.

Making the route an info route also enrolled it in proxy-only error logging, which
hands the request path to every failure callback. That gate exists to stop management
endpoints leaking temporary keys, and /key/info is immune only because its key is a
query param. The path form now rejects a plaintext key and asks for the hash, since a
key in a URL also reaches access logs and span names regardless of this gate.

The reservation layer builds no counter for a non-positive cap, so reporting its
tightened operator there turned a team, tag or end user sitting at max_budget 0.0 into
"exceeded" when both layers admit the request. The tightening now needs a positive cap.

Per-model budgets are enforced per request model, not per cap, so reporting the highest
counter under a cap claimed a denial that no request would hit. Each request model that
maps onto a cap gets its own row, identified by the model whose counter it reports.
Deployment names join the candidates, because routing straight at a deployment keys the
counter on that name rather than on a model group.

The reservation note claimed more than the layer delivers: a request it cannot price up
front is gated by the read-time check alone. Reworded rather than re-implemented, since
introspection cannot know the request.

Severity is now the fallback for a code a client has not been taught yet, not a ranking:
warning means the numbers may be incomplete or misread, info means they are accurate.

Also dropped the token end-user cap plumbing, which could only ever attribute the
caller's request-scoped cap to the inspected key, and memoised is_info_route, which the
pattern change had put 25 uncached regex builds per request behind.
…e a block

Per-model caps now report one row per request model, so a cap reachable under two
names arrives twice with the same label. The scope cell shows the request model
whose counter each row measures, or the two rows read as duplicates.

A key that opted into throttle_on_budget_exceeded was rendered as blocking, with
a "Blocks at" threshold and a red exceeded badge, when going over actually slows
requests instead of rejecting them. Such a row is never the cause of a denial.

Deadness is now read from the caveat code alone. Severity no longer tracks it in
either direction, since dead codes ship under both values, so an unclassified
code is assumed live: calling a row dead when it is not invites dismissing the
budget that actually stopped the request.
…he fact

`end_user_route_only` was tagged info while its text is the only place the row says
it applies to requests naming that end user. Nothing in `scope`, `max_budget` or
`comparison` scopes it, so a client that does not know the code had no way to learn
the row might not be in play, on the row most likely to answer "what blocked me".

The definition was the weaker half of that. "The numbers are accurate" left a note
about applicability unclassifiable, because a row can be perfectly accurate and still
not apply. Severity now turns on one question with no exceptions: does a field on the
row already carry this fact. `enforcement` carries alert_only, `comparison` carries
the reservation note, `window_start` carries the rolling window, `max_budget` carries
the personal budget a team key ignores, and `spend_state` carries the missing counter,
so those stay info. The five that only the note carries stay warning, and the end user
scoping note joins them. No other code changes, which is the reason to trust the rule.

A test now pins all eleven against the generated union, so a twelfth code fails until
someone decides which it is, mirroring the exhaustive switch the dashboard compiles.
…everities

A cap reachable under several request models emits a row each, so one can be
exceeded while its siblings are fine. Pin that the exceeded row floats to the top
and the siblings hold the server's order behind it rather than being reshuffled.

Severity now turns on whether the row already carries the fact in a field, which
is orthogonal to whether the row is dead, and all four combinations occur. Assert
the truth table so no fixture can quietly reassert that severity implies deadness.
…esolver

The reworded caveats moved the ceiling. The longest single note is now
reservation_blocks_at_limit at 192 characters, not per_model_counters at 165,
and the widest row runs 382 characters across three notes rather than 276.
Re-point the width pin at the note that actually holds the record and refresh
the snapshotted texts, so the fixtures stop asserting prose the server retired.
`code` and `severity` are the contract and `text` is explicitly free to be
reworded, so pinning the resolver's sentences was asserting the one field
guaranteed to move. It went stale three times in a day, silently each time,
because a fixture copy keeps passing long after the server stops agreeing.

Note texts are synthetic and derived from the code. The wrapping guard uses a
string past 500 characters rather than a copy of the current worst case, so no
rewording upstream can move it and it still fails if truncation returns.
… dead

A cold counter is transient, not a property of the budget. A key created minutes
ago with a per-model cap is fully live and blocks on the next request over it,
but the row read "Cannot trip", greyed, sorted below unlimited scopes, telling
someone to ignore the cap that stops them a minute later. Only a permanent
property counts as dead now, which leaves the project budget whose spend is
never incremented and the personal budget a team key never applies.

The fixture hid it: no_counter was built with no notes and a computed remaining,
neither of which the resolver can emit, so the row under test was not the row
users see. Fixtures now run through the invariants _to_entry guarantees, which
immediately caught a second one.
A key with throttle_on_budget_exceeded shipped as enforcement "hard" with a note
explaining otherwise, so any client that did not parse the prose reported a denial
that never happened, and flagged that row as the one that stopped the request. That
is the same mistake the joined note string made, one field up.

`enforcement` gains "throttled". At the limit such a key is admitted by both budget
layers: the read-time check sets a throttle percentage and returns, and the
reservation releases the entry it built for that one counter. What follows is a
reduced tpm/rpm, not a rejection. status stays "exceeded" and comparison stays ">="
because both are still true, and "throttled" is what stops them reading as a block.

The scoping matters and is now testable: only the key's own max_budget throttles.
Key windows, team, team member, user, org, tag and end user all still raise on the
same key, and the flag does nothing at all without a rate limit to scale or a
configured percentage. The note drops to info, since `enforcement` now carries the
fact and the note only explains the mechanism, which is the same rule the other ten
codes are classified by.

Also replaces the lru_cache on is_info_route with precomputed sets. The cache keyed
on a route carrying resolved ids, so its working set was unbounded on exactly the
traffic that would need it: a proxy with end user budgets serving per-resource GETs
would have paid a miss plus cache churn every request. Matching an exact frozenset
and the one templated pattern is 51x faster than the generic matcher on all-distinct
routes, with no state. A test pins it against check_route_access over 372 routes.
`enforcement` now carries "throttled" as its own mode, so the table reads the
field rather than inferring it from a note that only explains the mechanism. A
row is throttling even when the note is absent, and a note without the mode no
longer makes the table claim one.

The enforcement badge is a lookup exhaustive over the union, so a fourth mode
fails this build instead of silently defaulting to "Blocks requests", which is
the claim that was wrong in the first place.
Only a key's own max_budget throttles; its team, user, org and window budgets
still hard-block on the same key. Pin that a throttled key row and a blocking
team row coexist, with the highlight landing on the team row alone, so nothing
can later promote the mode from a row to a property of the key.
…one budget row per counter

The 400 rejecting a plaintext key in GET /key/{key_id}/budgets sits in the handler, which runs
after Depends(user_api_key_auth), so it never fires on the path that leaks: a wrong or expired
Authorization header rejects the request first, and the failure handler stamps the raw route onto
the server span and hands it to post_call_failure_hook, where it becomes call_type on the payload
every failure callback receives plus the OTEL llm.request.type and gen_ai.operation.name
attributes. Normalize the route instead, which is where the success path already collapsed it, and
add the three key management routes that take a key in the path to the placeholder list. Redaction
runs ahead of the memoized pass so no cache retains a live credential as a cache key. The 400
stays. Both existing routes, /key/{key_id}/regenerate and /key/{key_id}/reset_spend, had the same
hole on the span and are covered by the same change.

Per-model rows were one row per request model routed to a cap, but both readers fall back to the
provider-stripped model, so a single counter under gpt-5 was reported once each for openai/gpt-5,
azure/gpt-5 and bedrock/gpt-5, all reading the same balance and all claiming spend_state "live". A
$12 counter under a $12 cap rendered as four rows totalling $48, three of them asserting a counter
that does not exist. The lookup now reports which counter it landed on, and rows collapse on that,
so one counter is one row named after the counter rather than after whichever request model probed
it first. Two models with counters of their own still get a row each.

model_budget_fails_open always travelled with spend_state "no_counter", which made a cap whose
cache is merely cold indistinguishable from project_spend_not_tracked, a budget that can never
trip. spend_state already carries "cold", so the code is gone and the fails-open fact moves onto
per_model_counters, which rides every per-model row.

custom_auth_skips_read_time_checks is new: a custom auth callable returns its own token before any
of these checks run, and the wrapper skips common_checks too unless custom_auth_run_common_checks
is set, so on such a proxy none of these budgets are enforced at request time. Saying that only on
the end-user row understated it by a dozen scopes.

Router.deployment_names is appended to and never pruned, so a deleted deployment kept producing a
row; read the same names off the live model_list instead.
…forced as

A per-model row whose counter has not been created yet sent a null spend, so the table showed
"$0.00 of $40.00" beside a Remaining of "-": two cells contradicting each other on one row, and a
client left to guess whether the null meant zero.

The two non-live states are not the same kind of absence. A counter that does not exist yet is a
number we know, because the check reading it treats the absence as untouched headroom and compares
the cap against nothing, so zero is what will actually be enforced. Send it, with the full headroom
as remaining, and let spend_state say the zero is not a reading. A failed read is a number we do not
have, so it stays null in both fields; sending zero there would draw an exhausted budget as
untouched.
…ime checks

The note the resolver now puts on every row says budgets go unchecked, but the
reservation layer still enforces the scopes it covers, so treating the code as
fatal would grey out the entire table including the row that actually blocked
the request. Classify it as live and let the numbers speak.

Adopts the retired `model_budget_fails_open` code out of the exhaustive map,
and reports a cold per-model counter's remaining as the full cap rather than a
dash, matching the real 0.0 the resolver now sends.
@greptile-apps

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds key-budget diagnostics across the proxy and dashboard, while centralizing team-member budget enforcement so blocked keys can still access management reads

  • Adds self and hashed-key budget endpoints with scoped authorization and secret-safe path handling
  • Resolves applicable key, team, member, user, organization, project, tag, end-user, and proxy budget rows
  • Uses shared live spend-counter keys and exposes unknown or restricted states where appropriate
  • Adds a dashboard Budgets tab with verdict, bullet charts, and detailed rows
  • Extends backend and frontend regression coverage for budget enforcement and diagnostics

Confidence Score: 5/5

The PR appears safe to merge

No blocking failure remains

Important Files Changed

Filename Overview
litellm/proxy/management_endpoints/key_budget_resolver.py Resolves enforced budget scopes and degrades propagated entity-read failures to unknown; the previously discussed swallowed-helper behavior intentionally mirrors authentication
litellm/proxy/management_endpoints/key_management_endpoints.py Adds authorized self and hashed-key budget endpoints with plaintext-key rejection
litellm/proxy/auth/auth_checks.py Centralizes team-member budget resolution and enforcement while preserving explicit and default member caps and live spend reads
litellm/proxy/auth/user_api_key_auth.py Removes duplicate builder-local member-budget enforcement in favor of centralized common checks
ui/litellm-dashboard/src/components/templates/key_info_view.tsx Adds the key Budgets tab and integrates the new diagnostic views
ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyBudgets.ts Fetches typed budget diagnostics for the selected key

Reviews (6): Last reviewed commit: "docs(key budgets): state why a swallowed..." | Re-trigger Greptile

Comment thread litellm/proxy/management_endpoints/key_budget_resolver.py Outdated
Every entity loader in the resolver degrades a failed lookup to None, which the
planners cannot tell apart from an entity that is not there. A team, user, org,
project, tag or end user that could not be read therefore either dropped out of
the report or sat in it with no limit, reading as an unlimited scope, so the
budget most likely to be blocking the key was the one ruled out first.

Loaders now return an explicit unavailable sentinel. Each scope behind a failed
lookup gets its own row with status `unknown`, an `entity_unavailable` note and
the entity it belongs to, and a cap whose spend could not be read stops
reporting `ok`. The Budgets tab renders those rows as Unknown, ranks them above
every budget known to be under its limit, and never prints Unlimited for them.
@ryan-crabbe-berri

Copy link
Copy Markdown
Contributor Author

@greptile re review

Comment thread litellm/proxy/management_endpoints/key_budget_resolver.py
…ith a verdict

Six of the thirteen scopes could not answer "what stopped my request". The three
soft budgets only ever raise an alert, and the two per-model scopes fan out over
every model on the router rather than the key's own, so one wildcard cap on a
proxy with 500 deployments planned ~500 rows and fired ~500 cache reads for one
page. Neither has a counter the other scopes share, and the per-model ones have
no database column to fall back on either.

Dropping them takes the resolver from 1457 to 1172 lines and removes the
`_KeyModelSpend`, `_EndUserModelSpend` and `no_counter` machinery that existed
only for them, along with the router enumeration and the shared-counter collapse.
`BudgetEnforcement` is now `hard | throttled` and `BudgetSpendState` is now
`live | unavailable`.

The tab now opens with the answer rather than a grid to read: a verdict line that
names the blocking scope, or the one closest to its limit, or the scopes nobody
could read, over bullet graphs sorted by headroom. The table stays underneath as
the evidence, with every row two lines tall and caveats collapsed into their own
column, so its height no longer comes from whichever note happened to be longest.
Comment thread litellm/proxy/management_endpoints/key_management_endpoints.py
@veria-ai

veria-ai Bot commented Aug 20, 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: 2 · PR risk: 0/10

…itellm_key_budgets_endpoint

# Conflicts:
#	tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py
#	ui/litellm-dashboard/tsconfig.tsbuildinfo
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.72131% with 20 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
.../proxy/management_endpoints/key_budget_resolver.py 95.64% 18 Missing ⚠️
...y/management_endpoints/key_management_endpoints.py 94.87% 2 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_key_budgets_endpoint (f5080de) with litellm_internal_staging (65b4ac0)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (4b29702) 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.

…ssion

Belonging to a key's team is what grants /key/info, and this route reports
far more than /key/info does: the team's, the caller's own membership, the
owning user's, the organization's and the key's tag spend. A member whose
team has not granted /key/{key_id}/budgets could read all of it off someone
else's key. Proxy admins, the calling key itself and keys the caller owns
never reach the team's permission list.

Also pins the two end user failure modes the report has to tell apart: a
lookup that comes back empty still gets max_end_user_budget_id, because auth
falls back to it, while a lookup that raises gets an unknown row, because
both auth call sites wrap that same fallback in the try a raise escapes.
@ryan-crabbe-berri

Copy link
Copy Markdown
Contributor Author

@greptile re review

Comment thread litellm/proxy/management_endpoints/key_budget_resolver.py
…y not read them

The proxy row's limit and spend cover the whole deployment, not the key
being inspected, and every proxy-wide spend route is admin-only. Reporting
them on a route any key holder can call handed one tenant the total spend of
all of them.

The row still ships, because dropping it would read as "no proxy budget
applies", which is the guess this endpoint exists to remove. Its numbers are
blanked, `spend_state` gains `restricted` so a blank is never mistaken for a
failed read or a zero, and the row reports unknown: a caller who cannot see
the numbers cannot rule the scope out. The proxy budget row is not read from
the database at all for those callers.
@ryan-crabbe-berri

Copy link
Copy Markdown
Contributor Author

@greptile re review

…itellm_key_budgets_endpoint

# Conflicts:
#	tests/test_litellm/proxy/auth/test_auth_exception_handler.py
@ryan-crabbe-berri

Copy link
Copy Markdown
Contributor Author

@greptile re review

Comment thread litellm/proxy/management_endpoints/key_budget_resolver.py
…t unknown

Several of the helpers this reads entities through swallow a read error and
hand back an absence instead of raising. Enforcement calls those same helpers
and acts on that same absence, so a scope reported as unlimited is one no
check will apply on the request the row describes. _Unavailable is for the
helpers that do raise, whose failure leaves a budget that would still gate the
request, and those report unknown.

Pins both halves: a tag whose fetch failed open, and a team whose lookup raised.
@ryan-crabbe-berri

Copy link
Copy Markdown
Contributor Author

@greptile re review

tin-berri added a commit that referenced this pull request Aug 21, 2026
* feat(ui): add per-key Savings tab to key detail page

Adds a "Savings" tab to the key detail view, showing the same four metrics
and time-series chart as the proxy-wide Cost Optimization view, but scoped
to a single API key.

For org admins, the tab shows the key's full savings across all requests.
Non-admins see only their own requests on the key, with a scope note
explaining the limitation.

Root cause: userDailyActivityCall and userDailyActivityAggregatedCall
never forwarded an api_key query parameter to the backend, even though
both handlers already accept and filter by it.

Changes:

- networking.tsx: Add optional apiKey param to both daily activity call
  wrappers (appended to variadic options tuple for backward compatibility).

- costOptimizationUtils.ts: Extract shared metrics helpers (compressionOf,
  cachingOf, autorouterOf, savedTokensOf, cacheHitRatio) and shortDate
  so both UsageTab and KeySavingsTab use the same formulas and prevent
  divergence.

- useDailyActivityRange.ts: Refactor into useScopedDailyActivityRange(
  accessToken, scope: {userId, apiKey?}) for reuse-by-parameter unbundling.
  Role resolution stays at the entry point (useDailyActivityRange), not in
  a scoped caller. Update test expectations for new 6-arg tuple.

- UsageTab.tsx: Simplify by importing extracted helpers and SummaryCard
  component instead of defining them inline. No behavioral change.

- key_info_view.tsx: Insert "Savings" tab trigger between "Overview" and
  "Settings"; wire TabsContent to new KeySavingsTab component with lazy
  mounting (no keepMounted) to defer daily-activity fetch until tab opened.

- NEW: components/shared/SummaryCard.tsx — Shared presenter for four-tile
  summary row (label + value + hint + optional info popover). Extracted
  from UsageTab so both surfaces show identical tile layout without CSS
  divergence.

- NEW: components/templates/KeySavingsTab.tsx — Per-key view with admin/
  non-admin scope branching, empty-state messaging, same chart toggles
  and info popovers as UsageTab.

- NEW: components/templates/KeySavingsTab.test.tsx — 7 tests covering mount,
  loading state, empty state, scoping, and scope-note visibility.

Authorization: No new permission check. Both backends gate api_key filter
by the same user role check that governs the request itself. Non-admins
must send their own user_id and can only see their own keys.

Tests: 6121 pass (1 pre-existing failure unrelated to this change).

Prior art / collision note:
- PR #37570 (budgets tab) lands in same TabsList hunks as "Savings" tab,
  but different tab names so conflict trivial if both merge.
- PR #37659 (my own) adds progress/cancelled/cancel to DailyActivityRange,
  but this PR uses stable three-field interface from staging.

* fix(ui): scope spend view by the backend's admin-view contract, not all_admin_roles

Greptile flagged org admin handling on the key savings tab. The live bug it
described does not fire today: useAuthorized supplies session-role labels and
all_admin_roles only carries the raw org_admin spelling, so an org admin was
already scoped. That safety was accidental, so replace the predicate with
spendScopeUserId / hasProxyWideSpendView in utils/roles.ts, mirroring the
backend's user_api_key_has_admin_view (proxy admin and admin viewer only, org
admin excluded in both spellings), and use it in both useDailyActivityRange
and KeySavingsTab

Reclassify the KeySavingsTab render test as an integration test per the
repo's unit/integration split, move scope-resolution coverage to roles.test.ts
as a full role matrix, use real session-role values instead of raw ones, and
assert tile totals against non-empty metrics. Replace the nested ternary in
the chart body (frontend-lint error) with flat conditional rendering

* fix(ui): show auto-router savings as the fourth key-savings tile

Cache hit rate had displaced auto-router savings from the fourth slot,
diverging from the org-wide Cost Optimization page's tile order. Match
it: Total / Compression / Prompt caching / Auto-router, with cache hit
rate as a fifth tile.

* fix(ui): drop cache hit rate from the key savings tiles

Keep the four tiles this page is meant to show: total, compression,
prompt caching, and auto-router savings.

* fix(ui): stop an empty api_key from widening a key-scoped activity read

The paginated and aggregated daily-activity wrappers disagreed on an
empty filter value: the paginated one appended it, the aggregated one
coerced it to undefined with || and dropped it. Since the aggregated
call is the one tried first, an empty key hash would have silently
turned a key-scoped read into a proxy-wide one and reported every
key's savings as this key's. Use ?? so both send the filter through
and it matches nothing instead.

* style(ui): satisfy prettier and the inline-object lint rule in key savings tests

* refactor(ui): drop the cacheHitRatio extraction left over from the removed tile

* fix(ui): pass daily-activity filters raw so both transports agree at the null boundary

* refactor(ui): share the savings tiles and totals between both surfaces

The per-key Savings tab and the proxy-wide Cost Optimization tab carried a byte-identical
four-tile block, three long metric-definition strings included, and five identical useMemo
totals. Both now render SavingsTiles and total through useSavingsTotals, so the donut cannot
slice numbers the tile above it disagrees with.

* docs(ui): say request, not mount, in the savings tab comment

The comment claimed mounting eagerly would fire the rollup sweep, which reads as a claim about
the bundle. Only the request is deferred; the module ships with the key page either way.

* test(ui): pin the daily-activity args array against the real caller signatures

The sibling unit test mocks networking, so it checks the positional array against itself and
stays green when the array and a networking signature drift apart. Swapping user_id and api_key
in the aggregated signature alone passes there and fails here on user_id=hash-abc.

* style(ui): hoist the daily-activity query options out of the call argument

The four-property object literal tripped local/no-large-inline-object-arg. The violation predates
this branch, which only moved the line into the annotated range, and the rule count drops 550 to 549.
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.

1 participant