Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/test-unit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -211,23 +211,23 @@ jobs:
workers: 2
reruns: 2
timeout-minutes: 20
job-timeout-minutes: 55
job-timeout-minutes: 60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I dont think this change needs to be here


- shard: proxy-extras
artifact-name: proxy-extras
test-path: "tests/litellm-proxy-extras"
workers: 2
reruns: 2
timeout-minutes: 20
job-timeout-minutes: 55
job-timeout-minutes: 60

- shard: enterprise-package
artifact-name: enterprise-package
test-path: "tests/enterprise"
workers: 4
reruns: 2
timeout-minutes: 20
job-timeout-minutes: 55
job-timeout-minutes: 60

- shard: responses-caching-types
artifact-name: responses-caching-types
Expand Down
119 changes: 119 additions & 0 deletions litellm/proxy/management_endpoints/key_management_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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:

@ryan-crabbe-berri ryan-crabbe-berri Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 []
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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"}
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading