Repository navigation
feat(key management): show budget window usage on /key/info #37044
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ryan-crabbe-berri
merged 15 commits into
BerriAI:litellm_internal_staging
from
Thijmen:key-budget-window-usage
Sep 1, 2026
Merged
Changes from 11 commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
8c5fa0c
feat: add show budget window usage
Thijmen 34bc22f
fix: address pr comments
Thijmen db67baf
fix: drop Final from loop locals to stay within the basedpyright budget
Thijmen 3113003
refactor: build budget window usage without in-place mutation
Thijmen 9b30e73
fix: cap /v2/key/info batch size to bound spend-log query fan-out
Thijmen ffd23ab
style: apply ruff format to budget limits comprehension
Thijmen c7abb51
Merge branch 'litellm_internal_staging' of https://github.com/BerriAI…
Thijmen 4e3e7d8
test: cover soft budget row creation and windows without max_budget
Thijmen ab9a3f4
Merge remote-tracking branch 'origin/litellm_internal_staging' into k…
Thijmen f4406b4
fix(proxy): clear LIT002 in /v2/key/info batch-cap guard
Thijmen 41ab7e5
ci(test-unit): raise job timeout to 60m for the three 55m shards
Thijmen ca1f69f
Merge remote-tracking branch 'origin/litellm_internal_staging' into p…
ryan-crabbe-berri d9c43d5
fix(key management): read budget window usage from the window spend t…
ryan-crabbe-berri 46d073b
refactor(key): report window spend under budget_limits_usage instead …
ryan-crabbe-berri 760b864
refactor(key): trim budget_limits_usage entries to current_spend
ryan-crabbe-berri File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -107,6 +107,7 @@ | |
| TeamMemberPermissionChecks, | ||
| ) | ||
| from litellm.proxy.management_helpers.utils import management_endpoint_wrapper | ||
| from litellm.proxy.spend_tracking.budget_reservation import get_budget_window_start | ||
| from litellm.proxy.spend_tracking.spend_tracking_utils import _is_master_key | ||
| from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( | ||
| get_ui_settings_cached, | ||
|
|
@@ -3541,6 +3542,97 @@ async def _build_model_max_budget_usage( | |
| ) | ||
|
|
||
|
|
||
| def _budget_window_to_dict(window: object) -> Mapping[str, object] | None: | ||
| """Coerce a budget_limits entry to a dict; None when the entry is unusable.""" | ||
| if isinstance(window, dict): | ||
| return window | ||
| model_dump: Final = getattr(window, "model_dump", None) | ||
| if not callable(model_dump): | ||
| return None | ||
| try: | ||
| dumped: Final = model_dump() | ||
| except Exception: # noqa: BLE001 # model_dump implementations can raise arbitrary errors | ||
| return None | ||
| return dumped if isinstance(dumped, dict) else None | ||
|
|
||
|
|
||
| def _coerce_budget_limits(budget_limits: object) -> Sequence[object] | None: | ||
| """Coerce budget_limits to a sequence of windows, parsing JSON strings; None when unusable.""" | ||
| if isinstance(budget_limits, str): | ||
| try: | ||
| parsed: Final = json.loads(budget_limits) | ||
| except (TypeError, ValueError): | ||
| return None | ||
| return parsed if isinstance(parsed, list) else None | ||
| return budget_limits if isinstance(budget_limits, list) else None | ||
|
|
||
|
|
||
| def _parse_window_max_budget(value: object) -> float | None: | ||
| """Coerce a window's max_budget to float; None when absent or unparseable.""" | ||
| if isinstance(value, (int, float, str)): | ||
| try: | ||
| return float(value) | ||
| except (TypeError, ValueError): | ||
| return None | ||
| return None | ||
|
|
||
|
|
||
| async def _budget_window_with_usage(window: Mapping[str, object], api_key_hash: str) -> Mapping[str, object]: | ||
| """ | ||
| Return a copy of a budget window with current-window spend attached. | ||
|
|
||
| Per-window spend is not persisted in the DB; it lives in the cross-pod spend | ||
| counters (spend:key:{hashed_token}:window:{budget_duration}) that | ||
| _virtual_key_multi_budget_check enforces against, so we read the same | ||
| counters via get_current_spend. Passing max_budget + window_start makes the | ||
| read re-check against the authoritative spend-log aggregate when the counter | ||
| is stale-low (e.g. after a Redis flush), same as the enforcement path. | ||
| """ | ||
| from litellm.proxy.proxy_server import get_current_spend | ||
|
|
||
| duration: Final = window.get("budget_duration") | ||
| if not duration: | ||
| return dict(window) # mutable-ok: per-window response copy, built once per window | ||
| spend: Final = await get_current_spend( | ||
| counter_key=f"spend:key:{api_key_hash}:window:{duration}", | ||
| fallback_spend=0.0, | ||
| max_budget=_parse_window_max_budget(window.get("max_budget")), | ||
| window_entity_type="Key", | ||
| window_entity_id=api_key_hash, | ||
| window_start=get_budget_window_start(window), | ||
| ) | ||
| return {**window, "current_spend": round(spend, 4)} # mutable-ok: per-window response copy, built once per window | ||
|
|
||
|
|
||
| async def _budget_limits_entry_with_usage(window: object, api_key_hash: str) -> object: | ||
| """Return the window as an enriched dict when dict-coercible; the original entry otherwise.""" | ||
| coerced: Final = _budget_window_to_dict(window) | ||
| if not coerced: | ||
| return window | ||
| return await _budget_window_with_usage(window=coerced, api_key_hash=api_key_hash) | ||
|
|
||
|
|
||
| async def _budget_limits_with_usage(budget_limits: object, api_key_hash: str) -> Sequence[object] | None: | ||
| """ | ||
| Return budget_limits as window dicts with current-window spend attached. | ||
|
|
||
| None when budget_limits is not a usable (possibly JSON-encoded) list; the | ||
| caller keeps the original value then. Entries that are not dict-coercible | ||
| are preserved as-is. | ||
| """ | ||
| windows: Final = _coerce_budget_limits(budget_limits) | ||
| if windows is None: | ||
| return None | ||
| return [ # mutable-ok: entries are awaited, so they cannot be built inside a frozen wrapper | ||
| await _budget_limits_entry_with_usage(window=window, api_key_hash=api_key_hash) for window in windows | ||
| ] | ||
|
|
||
|
|
||
| # Caps per-request fan-out: each key with budget windows costs one spend-counter | ||
| # read (worst case a SpendLogs aggregation) per window. | ||
| MAX_KEY_INFO_KEYS_PER_REQUEST: Final = 100 | ||
|
|
||
|
|
||
| @router.post( | ||
| "/v2/key/info", | ||
| tags=["key management"], | ||
|
|
@@ -3583,6 +3675,17 @@ async def info_key_fn_v2( | |
| status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, | ||
| detail={"message": "Malformed request. No keys passed in."}, | ||
| ) | ||
| requested_key_count: Final = len(data.keys or ()) + len(data.key_aliases or ()) | ||
| if requested_key_count > MAX_KEY_INFO_KEYS_PER_REQUEST: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is a pretty big breaking behavior change, will revert because i refactored the budget windows spend tracking |
||
| raise HTTPException( | ||
| status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, | ||
| detail={ # mutable-ok: one-shot HTTPException payload matching the sibling detail dict above; never mutated after construction | ||
| "message": ( | ||
| f"Too many keys requested: {requested_key_count}. " | ||
| f"At most {MAX_KEY_INFO_KEYS_PER_REQUEST} keys and key_aliases combined per request." | ||
| ) | ||
| }, | ||
| ) | ||
|
|
||
| # Resolve key_aliases to tokens so we never pass token=None (unbounded query) | ||
| tokens_to_query: Final = list(data.keys) if data.keys else [] | ||
|
|
@@ -3625,6 +3728,13 @@ async def info_key_fn_v2( | |
| model_max_budget=model_max_budget, | ||
| user_api_key_cache=model_max_budget_limiter.dual_cache, | ||
| ) | ||
| if k_token_hash: | ||
| budget_limits_usage = await _budget_limits_with_usage( | ||
| budget_limits=k_dict.get("budget_limits"), | ||
| api_key_hash=k_token_hash, | ||
| ) | ||
| if budget_limits_usage is not None: | ||
| k_dict["budget_limits"] = budget_limits_usage | ||
|
|
||
| filtered_key_info.append(k_dict) | ||
| return {"key": data.keys, "info": filtered_key_info} | ||
|
|
@@ -3661,6 +3771,9 @@ async def info_key_fn( | |
| - model_max_budget: dict - Per-model budgets, e.g. {"gpt-4": {"budget_limit": 0.0005, "time_period": "30d"}} | ||
| - model_max_budget_usage: dict | None - Current-window spend per model, present only when | ||
| the key has per-model budgets | ||
| - budget_limits: list | None - Concurrent budget windows. Each entry includes | ||
| current_spend: spend accumulated in the window so far (read from the same cross-pod | ||
| spend counter the budget enforcement uses) | ||
| - models: list - Model_name's the key is allowed to call | ||
| - tpm_limit / rpm_limit: int | None - Tokens and requests per minute limits | ||
| - metadata: dict - Metadata for the key, e.g. {"team": "core-infra"} | ||
|
|
@@ -3740,6 +3853,12 @@ async def info_key_fn( | |
| model_max_budget=model_max_budget, | ||
| user_api_key_cache=model_max_budget_limiter.dual_cache, | ||
| ) | ||
| budget_limits_usage: Final = await _budget_limits_with_usage( | ||
| budget_limits=key_info.get("budget_limits"), | ||
| api_key_hash=key_token_hash, | ||
| ) | ||
| if budget_limits_usage is not None: | ||
| key_info["budget_limits"] = budget_limits_usage | ||
|
|
||
| # Attach object_permission if object_permission_id is set | ||
| key_info = await attach_object_permission_to_dict(key_info, prisma_client) | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I dont think this change needs to be here