This commit is contained in:
Thijmen Stavenuiter 2026-08-27 16:38:11 -04:00 committed by GitHub
commit e40a73b502
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 544 additions and 0 deletions

View file

@ -107,6 +107,7 @@ from litellm.proxy.management_helpers.team_member_permission_checks import (
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,
@ -3515,6 +3516,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"],
@ -3557,6 +3649,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:
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 []
@ -3599,6 +3702,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}
@ -3635,6 +3745,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"}
@ -3714,6 +3827,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)

View file

@ -538,6 +538,57 @@ async def test_key_generation_with_object_permission(monkeypatch):
assert key_insert_calls[0]["data"].get("object_permission_id") == "objperm123"
@pytest.mark.asyncio
async def test_generate_key_with_soft_budget_creates_budget_row(monkeypatch):
"""soft_budget on /key/generate must create a budget table row and link its budget_id to the key."""
mock_prisma_client = AsyncMock()
mock_prisma_client.jsonify_object = lambda data: data
mock_prisma_client.db = MagicMock()
mock_budget_create = AsyncMock(return_value=MagicMock(budget_id="budget-soft-123"))
mock_prisma_client.db.litellm_budgettable = MagicMock()
mock_prisma_client.db.litellm_budgettable.create = mock_budget_create
async def _insert_data_side_effect(*args, **kwargs):
if kwargs.get("table_name") == "user":
return MagicMock(models=[], spend=0)
return MagicMock(
token="hashed_token_soft",
litellm_budget_table=None,
object_permission=None,
)
mock_prisma_client.insert_data = AsyncMock(side_effect=_insert_data_side_effect)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
from litellm.proxy._types import GenerateKeyRequest, LitellmUserRoles
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth
from litellm.proxy.management_endpoints.key_management_endpoints import (
generate_key_fn,
)
await generate_key_fn(
data=GenerateKeyRequest(soft_budget=5.0),
user_api_key_dict=UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
api_key="sk-1234",
user_id="admin-1",
),
)
mock_budget_create.assert_awaited_once()
created_budget = mock_budget_create.call_args.kwargs["data"]
assert created_budget["soft_budget"] == 5.0
assert created_budget["created_by"] == "admin-1"
key_insert_calls = [
call.kwargs
for call in mock_prisma_client.insert_data.call_args_list
if call.kwargs.get("table_name") == "key"
]
assert len(key_insert_calls) == 1
assert key_insert_calls[0]["data"].get("budget_id") == "budget-soft-123"
@pytest.mark.asyncio
async def test_generate_key_debug_log_never_contains_raw_token(monkeypatch, caplog):
"""Regression for LIT-4356: /key/generate must never emit the raw virtual key
@ -13895,6 +13946,377 @@ async def test_info_key_fn_v2_budget_table_fallback(monkeypatch):
mock_prisma_client.db.query_raw.assert_not_awaited()
@pytest.mark.asyncio
async def test_info_key_fn_budget_limits_includes_current_spend(monkeypatch):
"""
/key/info should attach current_spend to each budget_limits window, read from
the same spend counter (spend:key:{token}:window:{duration}) that budget
enforcement uses.
"""
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy._types import LiteLLM_VerificationToken
from litellm.proxy.management_endpoints.key_management_endpoints import info_key_fn
test_key_token = "hashed_token_window_test"
budget_limits = [
{
"reset_at": "2026-08-15T18:00:00+00:00",
"max_budget": 2.0,
"budget_duration": "1h",
}
]
mock_prisma_client = AsyncMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mock_user_api_key_cache = AsyncMock()
monkeypatch.setattr(
"litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache
)
mock_get_current_spend = AsyncMock(return_value=0.73)
monkeypatch.setattr(
"litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend
)
mock_key_info = MagicMock(spec=LiteLLM_VerificationToken)
mock_key_info.token = test_key_token
mock_key_info.object_permission_id = None
mock_key_info.user_id = "user-w"
mock_key_info.team_id = None
mock_key_info.litellm_budget_table = None
mock_key_info.model_dump.return_value = {
"token": test_key_token,
"budget_limits": [dict(w) for w in budget_limits],
"user_id": "user-w",
"team_id": None,
"object_permission_id": None,
"litellm_budget_table": None,
}
mock_key_info.dict.return_value = mock_key_info.model_dump.return_value
mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(
return_value=mock_key_info
)
user_api_key_dict = UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
api_key="sk-test-window-key",
)
result = await info_key_fn(
key="sk-test-window-key",
user_api_key_dict=user_api_key_dict,
)
windows = result["info"]["budget_limits"]
assert len(windows) == 1
assert windows[0]["current_spend"] == 0.73
assert windows[0]["max_budget"] == 2.0
assert windows[0]["budget_duration"] == "1h"
mock_get_current_spend.assert_awaited_once()
call_kwargs = mock_get_current_spend.await_args.kwargs
assert call_kwargs["counter_key"] == f"spend:key:{test_key_token}:window:1h"
assert call_kwargs["max_budget"] == 2.0
assert call_kwargs["window_entity_type"] == "Key"
assert call_kwargs["window_entity_id"] == test_key_token
assert call_kwargs["window_start"] is not None
@pytest.mark.asyncio
async def test_info_key_fn_no_budget_limits_skips_spend_lookup(monkeypatch):
"""Keys without budget_limits should not trigger window spend lookups."""
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy._types import LiteLLM_VerificationToken
from litellm.proxy.management_endpoints.key_management_endpoints import info_key_fn
test_key_token = "hashed_token_no_windows"
mock_prisma_client = AsyncMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mock_user_api_key_cache = AsyncMock()
monkeypatch.setattr(
"litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache
)
mock_get_current_spend = AsyncMock(return_value=0.0)
monkeypatch.setattr(
"litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend
)
mock_key_info = MagicMock(spec=LiteLLM_VerificationToken)
mock_key_info.token = test_key_token
mock_key_info.object_permission_id = None
mock_key_info.user_id = "user-nw"
mock_key_info.team_id = None
mock_key_info.litellm_budget_table = None
mock_key_info.model_dump.return_value = {
"token": test_key_token,
"budget_limits": None,
"user_id": "user-nw",
"team_id": None,
"object_permission_id": None,
"litellm_budget_table": None,
}
mock_key_info.dict.return_value = mock_key_info.model_dump.return_value
mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(
return_value=mock_key_info
)
user_api_key_dict = UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
api_key="sk-test-no-window-key",
)
result = await info_key_fn(
key="sk-test-no-window-key",
user_api_key_dict=user_api_key_dict,
)
assert result["info"]["budget_limits"] is None
mock_get_current_spend.assert_not_awaited()
@pytest.mark.asyncio
async def test_info_key_fn_v2_budget_limits_includes_current_spend(monkeypatch):
"""/v2/key/info should attach current_spend to each budget_limits window."""
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy._types import KeyRequest, LiteLLM_VerificationToken
from litellm.proxy.management_endpoints.key_management_endpoints import (
info_key_fn_v2,
)
test_key_token = "hashed_token_v2_window_test"
mock_prisma_client = AsyncMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mock_user_api_key_cache = AsyncMock()
monkeypatch.setattr(
"litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache
)
mock_get_current_spend = AsyncMock(return_value=1.25)
monkeypatch.setattr(
"litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend
)
mock_key = MagicMock(spec=LiteLLM_VerificationToken)
mock_key.token = test_key_token
mock_key.user_id = "user-v2-w"
mock_key.team_id = None
mock_key.model_dump.return_value = {
"token": test_key_token,
"budget_limits": [
{
"reset_at": "2026-08-15T18:00:00+00:00",
"max_budget": 2.0,
"budget_duration": "1h",
},
{
"reset_at": "2026-08-16T00:00:00+00:00",
"max_budget": 20.0,
"budget_duration": "1d",
},
],
"user_id": "user-v2-w",
"team_id": None,
"litellm_budget_table": None,
}
mock_key.dict.return_value = mock_key.model_dump.return_value
mock_prisma_client.get_data = AsyncMock(return_value=[mock_key])
user_api_key_dict = UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
api_key="sk-admin-v2-w",
)
result = await info_key_fn_v2(
data=KeyRequest(keys=[test_key_token]),
user_api_key_dict=user_api_key_dict,
)
assert len(result["info"]) == 1
windows = result["info"][0]["budget_limits"]
assert len(windows) == 2
assert windows[0]["current_spend"] == 1.25
assert windows[1]["current_spend"] == 1.25
assert mock_get_current_spend.await_count == 2
counter_keys = {
call.kwargs["counter_key"] for call in mock_get_current_spend.await_args_list
}
assert counter_keys == {
f"spend:key:{test_key_token}:window:1h",
f"spend:key:{test_key_token}:window:1d",
}
@pytest.mark.asyncio
async def test_info_key_fn_v2_rejects_oversized_batch(monkeypatch):
"""/v2/key/info must reject over-cap batches before doing any DB work."""
from unittest.mock import AsyncMock
from litellm.proxy._types import KeyRequest, ProxyException
from litellm.proxy.management_endpoints.key_management_endpoints import (
MAX_KEY_INFO_KEYS_PER_REQUEST,
info_key_fn_v2,
)
mock_prisma_client = AsyncMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", AsyncMock())
user_api_key_dict = UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
api_key="sk-admin-batch-cap",
)
with pytest.raises(ProxyException) as exc_info:
await info_key_fn_v2(
data=KeyRequest(
keys=[f"hash-{i}" for i in range(MAX_KEY_INFO_KEYS_PER_REQUEST)],
key_aliases=["alias-over-cap"],
),
user_api_key_dict=user_api_key_dict,
)
assert exc_info.value.code == "422"
mock_prisma_client.get_data.assert_not_awaited()
@pytest.mark.asyncio
async def test_budget_limits_with_usage_json_string_input(monkeypatch):
"""budget_limits stored as a JSON string should be parsed and annotated."""
import json as json_module
from unittest.mock import AsyncMock
from litellm.proxy.management_endpoints.key_management_endpoints import (
_budget_limits_with_usage,
)
mock_get_current_spend = AsyncMock(return_value=0.5)
monkeypatch.setattr(
"litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend
)
raw = json_module.dumps(
[{"budget_duration": "1h", "max_budget": 2.0, "reset_at": None}]
)
result = await _budget_limits_with_usage(budget_limits=raw, api_key_hash="hash-1")
assert result == [
{
"budget_duration": "1h",
"max_budget": 2.0,
"reset_at": None,
"current_spend": 0.5,
}
]
mock_get_current_spend.assert_awaited_once()
@pytest.mark.asyncio
async def test_budget_limits_with_usage_skips_unusable_inputs(monkeypatch):
"""Invalid JSON strings, non-list values, and malformed windows are skipped."""
from unittest.mock import AsyncMock
from litellm.proxy.management_endpoints.key_management_endpoints import (
_budget_limits_with_usage,
)
mock_get_current_spend = AsyncMock(return_value=0.0)
monkeypatch.setattr(
"litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend
)
# invalid JSON string and non-list values return None: callers keep the original
assert await _budget_limits_with_usage(budget_limits="{not json", api_key_hash="hash-1") is None
assert await _budget_limits_with_usage(budget_limits={"budget_duration": "1h"}, api_key_hash="hash-1") is None
# windows that are falsy, missing budget_duration, or not dict-like
windows = [
{},
{"max_budget": 2.0},
{"budget_duration": "1h", "max_budget": "not-a-number"},
42,
]
result = await _budget_limits_with_usage(budget_limits=windows, api_key_hash="hash-1")
# only the well-formed window (with unparseable max_budget coerced to None)
# triggers a spend lookup
mock_get_current_spend.assert_awaited_once()
call_kwargs = mock_get_current_spend.await_args.kwargs
assert call_kwargs["counter_key"] == "spend:key:hash-1:window:1h"
assert call_kwargs["max_budget"] is None
assert result is not None
assert result[0] == {}
assert result[1] == {"max_budget": 2.0}
assert result[2] == {"budget_duration": "1h", "max_budget": "not-a-number", "current_spend": 0.0}
assert result[3] == 42
# input is not mutated
assert windows[2] == {"budget_duration": "1h", "max_budget": "not-a-number"}
@pytest.mark.asyncio
async def test_budget_limits_with_usage_window_without_max_budget(monkeypatch):
"""A window with only budget_duration still gets current_spend, read without a budget ceiling."""
from unittest.mock import AsyncMock
from litellm.proxy.management_endpoints.key_management_endpoints import (
_budget_limits_with_usage,
)
mock_get_current_spend = AsyncMock(return_value=0.75)
monkeypatch.setattr(
"litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend
)
result = await _budget_limits_with_usage(
budget_limits=[{"budget_duration": "2d"}], api_key_hash="hash-no-max"
)
assert result == [{"budget_duration": "2d", "current_spend": 0.75}]
call_kwargs = mock_get_current_spend.await_args.kwargs
assert call_kwargs["counter_key"] == "spend:key:hash-no-max:window:2d"
assert call_kwargs["max_budget"] is None
@pytest.mark.asyncio
async def test_budget_limits_with_usage_pydantic_windows(monkeypatch):
"""Window objects with model_dump() are converted to dicts; failing windows pass through."""
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy.management_endpoints.key_management_endpoints import (
_budget_limits_with_usage,
)
mock_get_current_spend = AsyncMock(return_value=1.0)
monkeypatch.setattr(
"litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend
)
good_window = MagicMock()
good_window.model_dump.return_value = {
"budget_duration": "7d",
"max_budget": 10.0,
"reset_at": None,
}
bad_window = MagicMock()
bad_window.model_dump.side_effect = ValueError("boom")
result = await _budget_limits_with_usage(
budget_limits=[good_window, bad_window], api_key_hash="hash-2"
)
# good window converted to dict and annotated; failing window left as-is
assert result is not None
assert isinstance(result[0], dict)
assert result[0]["current_spend"] == 1.0
assert result[1] is bad_window
mock_get_current_spend.assert_awaited_once()
@pytest.mark.asyncio
async def test_info_key_fn_reads_the_configured_budget_model_key(monkeypatch):
"""/key/info reads the one counter enforcement reads: the configured budget model.

View file

@ -7489,6 +7489,9 @@ export interface paths {
* - 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"}