mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
fix(proxy): /key/bulk_update writes only the fields each item carries
A bulk item that carried only tags reached the DB with max_budget, team_id, and budget_id as explicit nulls, wiping the key's budget and detaching it from its team. The per-key update is now built from the fields the item actually set, so a field left out keeps its value and an explicit null still clears it, the same as /key/update. Items carrying a field the bulk path cannot apply (object_permission and the like) are rejected with 422 instead of being silently dropped.
This commit is contained in:
parent
5fc510a6fd
commit
bdbe265c70
5 changed files with 96 additions and 12 deletions
|
|
@ -19622,7 +19622,7 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"description": "\n Unified rate-limit error.\n\n Every rate-limit condition surfaced by litellm \u2014 whether it originated from\n an upstream LLM provider, a vendor batch endpoint, or one of litellm's own\n proxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\n max-iterations, etc.) \u2014 is raised as an instance of this class.\n\n The :attr:`category` attribute lets callers distinguish the source. See\n :class:`RateLimitErrorCategory` for the available values.\n "
|
||||
"description": "\nUnified rate-limit error.\n\nEvery rate-limit condition surfaced by litellm \u2014 whether it originated from\nan upstream LLM provider, a vendor batch endpoint, or one of litellm's own\nproxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\nmax-iterations, etc.) \u2014 is raised as an instance of this class.\n\nThe :attr:`category` attribute lets callers distinguish the source. See\n:class:`RateLimitErrorCategory` for the available values.\n"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
|
|
|
|||
|
|
@ -3517,7 +3517,10 @@ async def bulk_update_keys(
|
|||
- max_budget: Optional[float] - Max budget for key
|
||||
- team_id: Optional[str] - Team ID associated with key
|
||||
- tags: Optional[List[str]] - Tags for organizing keys
|
||||
|
||||
|
||||
Only the fields an item carries are written: a field left out keeps its current value and an
|
||||
explicit null clears it, the same as /key/update. An item carrying any other field is rejected with 422.
|
||||
|
||||
Returns:
|
||||
- total_requested: int - Total number of keys requested for update
|
||||
- successful_updates: List[SuccessfulKeyUpdate] - List of successfully updated keys with their updated info
|
||||
|
|
@ -3586,15 +3589,8 @@ async def bulk_update_keys(
|
|||
|
||||
for key_update_item in data.keys:
|
||||
try:
|
||||
update_key_request = UpdateKeyRequest(
|
||||
key=key_update_item.key,
|
||||
budget_id=key_update_item.budget_id,
|
||||
max_budget=key_update_item.max_budget,
|
||||
team_id=key_update_item.team_id,
|
||||
tags=key_update_item.tags,
|
||||
)
|
||||
updated_key_info = await _process_single_key_update(
|
||||
update_key_request=update_key_request,
|
||||
update_key_request=UpdateKeyRequest.model_validate(key_update_item.model_dump(exclude_unset=True)),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
prisma_client=prisma_client,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ class KeySearchWhere(TypedDict):
|
|||
|
||||
|
||||
class BulkUpdateKeyRequestItem(BaseModel):
|
||||
"""Individual key update request item"""
|
||||
"""One /key/bulk_update item; only the fields it carries are written, and unknown fields are rejected."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
key: str # Key identifier (token)
|
||||
budget_id: str | None = None # Budget ID associated with the key
|
||||
|
|
|
|||
|
|
@ -7097,6 +7097,89 @@ async def test_list_key_helper_applies_search_to_prisma_where():
|
|||
assert _search_clause("key-id-123", "key-id-123") in where["AND"], f"search not in Prisma where: {where}"
|
||||
|
||||
|
||||
_BULK_UPDATE_TOKEN: Final = "1f2e3d4c5b6a79880123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
|
||||
async def _bulk_update_one_key(monkeypatch, item_payload: Mapping[str, object]) -> Mapping[str, object]:
|
||||
"""Runs /key/bulk_update with one item against a budgeted team key and returns the row written to the DB."""
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import bulk_update_keys
|
||||
from litellm.types.proxy.management_endpoints.key_management_endpoints import BulkUpdateKeyRequest
|
||||
|
||||
key_in_db = LiteLLM_VerificationToken(
|
||||
token=_BULK_UPDATE_TOKEN, user_id="test-user", team_id="team-1", max_budget=100.0, budget_id="budget-1"
|
||||
)
|
||||
mock_prisma_client = AsyncMock()
|
||||
mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=key_in_db)
|
||||
mock_prisma_client.update_data = AsyncMock(return_value={"data": {"token": _BULK_UPDATE_TOKEN}})
|
||||
_setup_update_key_mocks(monkeypatch, mock_prisma_client)
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: the handler reads the cache and hook singletons from module globals, no injection seam
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints._delete_cache_key_object",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch( # test-quality-ok: the permission check is a classmethod the handler calls directly, no injection seam
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints.TeamMemberPermissionChecks.can_team_member_execute_key_management_endpoint",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch( # test-quality-ok: the audit hook is a classmethod the handler calls directly, no injection seam
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints.KeyManagementEventHooks.async_key_updated_hook",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
):
|
||||
response = await bulk_update_keys(
|
||||
data=BulkUpdateKeyRequest.model_validate({"keys": [{"key": _BULK_UPDATE_TOKEN, **item_payload}]}),
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin", user_id="admin-user"
|
||||
),
|
||||
litellm_changed_by=None,
|
||||
)
|
||||
|
||||
assert response.failed_updates == []
|
||||
return mock_prisma_client.update_data.call_args.kwargs["data"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_update_keys_item_without_a_field_leaves_that_column_alone(monkeypatch):
|
||||
"""A tags-only item used to reach the DB with max_budget, team_id, and budget_id as explicit
|
||||
nulls, so tagging a key wiped its budget and detached it from its team."""
|
||||
written = await _bulk_update_one_key(monkeypatch, {"tags": ["team-a"]})
|
||||
|
||||
assert written["metadata"]["tags"] == ["team-a"]
|
||||
assert not {"max_budget", "team_id", "budget_id"} & written.keys()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_update_keys_explicit_null_still_clears_the_field(monkeypatch):
|
||||
"""Sending `"max_budget": null` on an item is a request to remove the budget, as on /key/update."""
|
||||
written = await _bulk_update_one_key(monkeypatch, {"max_budget": None})
|
||||
|
||||
assert written["max_budget"] is None
|
||||
assert not {"team_id", "budget_id"} & written.keys()
|
||||
|
||||
|
||||
def test_bulk_update_keys_rejects_a_field_the_bulk_path_cannot_apply():
|
||||
"""`object_permission` used to be accepted with 200 and dropped, leaving an item that carried
|
||||
nothing but the key, so the call wiped the key's budget instead of granting the permission."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import router
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
test_app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin", user_id="admin-user"
|
||||
)
|
||||
response = TestClient(test_app).post(
|
||||
"/key/bulk_update",
|
||||
json={"keys": [{"key": _BULK_UPDATE_TOKEN, "object_permission": {"vector_stores": ["vs-1"]}}]},
|
||||
)
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
assert "object_permission" in response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_key_negative_max_budget():
|
||||
"""
|
||||
|
|
|
|||
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -7694,6 +7694,9 @@ export interface paths {
|
|||
* - team_id: Optional[str] - Team ID associated with key
|
||||
* - tags: Optional[List[str]] - Tags for organizing keys
|
||||
*
|
||||
* Only the fields an item carries are written: a field left out keeps its current value and an
|
||||
* explicit null clears it, the same as /key/update. An item carrying any other field is rejected with 422.
|
||||
*
|
||||
* Returns:
|
||||
* - total_requested: int - Total number of keys requested for update
|
||||
* - successful_updates: List[SuccessfulKeyUpdate] - List of successfully updated keys with their updated info
|
||||
|
|
@ -25237,7 +25240,7 @@ export interface components {
|
|||
};
|
||||
/**
|
||||
* BulkUpdateKeyRequestItem
|
||||
* @description Individual key update request item
|
||||
* @description One /key/bulk_update item; only the fields it carries are written, and unknown fields are rejected.
|
||||
*/
|
||||
BulkUpdateKeyRequestItem: {
|
||||
/** Budget Id */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue