feat(key management): show every budget that applies to a key - #37570
Open
ryan-crabbe-berri wants to merge 37 commits into
Open
feat(key management): show every budget that applies to a key#37570ryan-crabbe-berri wants to merge 37 commits into
ryan-crabbe-berri wants to merge 37 commits into
Conversation
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.
Contributor
Greptile SummaryThe PR adds key-budget diagnostics across the proxy and dashboard, while centralizing team-member budget enforcement so blocked keys can still access management reads
Confidence Score: 5/5The PR appears safe to merge No blocking failure remains
|
| 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
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.
Contributor
Author
|
@greptile re review |
…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.
Contributor
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo 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 Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…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.
Contributor
Author
|
@greptile re review |
…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.
Contributor
Author
|
@greptile re review |
…itellm_key_budgets_endpoint # Conflicts: # tests/test_litellm/proxy/auth/test_auth_exception_handler.py
Contributor
Author
|
@greptile re review |
…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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TLDR
Problem this solves:
How it solves it:
GET /key/{key_id}/budgetslists every budget applying to a key/key/{key_id}/budgetspermissionUser Flow
Before: a developer whose key, team and user all read unlimited gets a 429 naming nothing, and cannot find the budget responsible
Budget has been exceeded! Current cost: 0.008835, Max budget: 0.003, naming no team, user or organizationAfter: the same 429 is traceable to one named budget in a single request
Relevant issues
Linear ticket
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaito 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:
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
Case 2: an exhausted team member budget blocks reads
After (2d962c4)
Case 1: find the budget closest to blocking
The spend is the live counter, not the stored column:
/key/inforead 0.005415 at the same moment this read 0.008985. Note also thatproxyblocks at>while every other hard scope here blocks at>=.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.
The last row reports
max_budget: nulleven though that user carries $0.05 in the database, because a team key does not inherit a personal budget unlessapply_user_budget_to_team_keysis on.Case 2: an exhausted team member budget no longer blocks reads
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.
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:
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:
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:
The key from case 3. The three scopes behind the unreadable team sort to the top as Unknown, so nobody rules them out:
Type
🆕 New Feature
🐛 Bug Fix
Caveats (if any)
/key/route values are redacted in failure logs and spanscan reject a request and the per-model scopes fanned out over every model on the
router rather than the key's own
/key/{key_id}/budgets; proxy admins, the calling key and keys the caller ownsare unaffected
restrictedwith no numbers to anyone but a proxy admin,since its limit and spend cover the whole deployment rather than the key
statusgained anunknownvalue for scopes the server could not readthe table on its own
Final Attestation