Merge pull request #26004 from BerriAI/litellm_fix-preserve-reserved-metadata

fix: preserve service_account_id in metadata on /key/update
This commit is contained in:
ryan-crabbe-berri 2026-04-25 09:28:47 -07:00 • committed by GitHub
commit 9839ab7f96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 152 additions and 5 deletions

View file

@ -4123,6 +4123,12 @@ LiteLLM_ManagementEndpoint_MetadataFields_Premium = [
"allowed_passthrough_routes",
]
# Metadata keys that are immutable once set: preserved when an update omits them,
# and rejected (400) when an update tries to change them.
LiteLLM_Reserved_Metadata_Fields = [
"service_account_id",
]
class ProviderBudgetResponseObject(LiteLLMPydanticObjectBase):
"""

View file

@ -1523,6 +1523,22 @@ def prepare_metadata_fields(
casted_metadata = cast(dict, non_default_values["metadata"])
# Reserved metadata fields are immutable once set. Preserve the existing value
# when omitted, reject any explicit attempt to change it (including null).
for reserved_field in LiteLLM_Reserved_Metadata_Fields:
existing_value = existing_metadata.get(reserved_field)
if existing_value is None:
continue
if casted_metadata is None or (
reserved_field in casted_metadata
and casted_metadata[reserved_field] != existing_value
):
raise HTTPException(
status_code=400,
detail=f"{reserved_field} is immutable once set and cannot be changed via update.",
)
casted_metadata[reserved_field] = existing_value
data_json = data.model_dump(exclude_unset=True, exclude_none=True)
try:

View file

@ -686,12 +686,13 @@ async def test_proxy_config_update_from_db():
@pytest.mark.asyncio
async def test_prepare_key_update_data():
from litellm.proxy._types import UpdateKeyRequest
from litellm.proxy._types import LiteLLM_VerificationToken, UpdateKeyRequest
from litellm.proxy.management_endpoints.key_management_endpoints import (
prepare_key_update_data,
)
existing_key_row = MagicMock()
existing_key_row = MagicMock(spec=LiteLLM_VerificationToken)
existing_key_row.metadata = {}
data = UpdateKeyRequest(key="test_key", models=["gpt-4"], duration="120s")
updated_data = await prepare_key_update_data(data, existing_key_row)
assert "expires" in updated_data

View file

@ -1211,6 +1211,131 @@ async def test_update_service_account_works_with_team_id():
await prepare_key_update_data(data=data, existing_key_row=existing_key)
@pytest.mark.asyncio
async def test_update_preserves_service_account_id_when_metadata_replaced():
"""
Regression: /key/update wholesale-replaced metadata, silently dropping
service_account_id. The pre-call check then treated the key as a regular
key and bypassed service_account_settings.enforced_params.
"""
data = UpdateKeyRequest(
key="sk-1",
metadata={"unrelated": "value"},
team_id="IJ",
)
existing_key = LiteLLM_VerificationToken(
token="hashed",
team_id="IJ",
metadata={"service_account_id": "sa-123"},
)
result = await prepare_key_update_data(data=data, existing_key_row=existing_key)
assert result["metadata"]["service_account_id"] == "sa-123"
assert result["metadata"]["unrelated"] == "value"
@pytest.mark.asyncio
async def test_update_rejects_service_account_id_overwrite():
"""
Once assigned, a key's service_account_id is an identity marker — rebinding
it via update would break spend attribution. Reject rather than silently
ignore so scripted callers surface the bug.
"""
data = UpdateKeyRequest(
key="sk-1",
metadata={"service_account_id": "sa-new"},
team_id="IJ",
)
existing_key = LiteLLM_VerificationToken(
token="hashed",
team_id="IJ",
metadata={"service_account_id": "sa-old"},
)
with pytest.raises(HTTPException) as exc_info:
await prepare_key_update_data(data=data, existing_key_row=existing_key)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_update_allows_matching_service_account_id():
"""Resending the same value (e.g. UI round-trip) is a no-op, not a conflict."""
data = UpdateKeyRequest(
key="sk-1",
metadata={"service_account_id": "sa-123", "other": "value"},
team_id="IJ",
)
existing_key = LiteLLM_VerificationToken(
token="hashed",
team_id="IJ",
metadata={"service_account_id": "sa-123"},
)
result = await prepare_key_update_data(data=data, existing_key_row=existing_key)
assert result["metadata"]["service_account_id"] == "sa-123"
assert result["metadata"]["other"] == "value"
@pytest.mark.asyncio
async def test_update_rejects_explicit_null_service_account_id():
"""
Explicit null is an attempt to clear — not an omission. Silently ignoring
it would let a caller think they cleared the field when they didn't, so
treat it the same as any other rebind attempt and return 400.
"""
data = UpdateKeyRequest(
key="sk-1",
metadata={"service_account_id": None},
team_id="IJ",
)
existing_key = LiteLLM_VerificationToken(
token="hashed",
team_id="IJ",
metadata={"service_account_id": "sa-old"},
)
with pytest.raises(HTTPException) as exc_info:
await prepare_key_update_data(data=data, existing_key_row=existing_key)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_update_rejects_whole_metadata_null_on_service_account_key():
"""
`metadata: null` on a key with a reserved field would have dereferenced None
inside the reserved-field loop and returned a 500. Must surface as a 400
since the effect would be to clear an immutable field.
"""
data = UpdateKeyRequest(key="sk-1", metadata=None, team_id="IJ")
existing_key = LiteLLM_VerificationToken(
token="hashed",
team_id="IJ",
metadata={"service_account_id": "sa-123"},
)
with pytest.raises(HTTPException) as exc_info:
await prepare_key_update_data(data=data, existing_key_row=existing_key)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_update_without_metadata_still_preserves_existing():
"""Omitting metadata entirely must not drop existing metadata fields."""
data = UpdateKeyRequest(key="sk-1", max_budget=100)
existing_key = LiteLLM_VerificationToken(
token="hashed",
team_id="IJ",
metadata={"service_account_id": "sa-123", "other": "kept"},
)
result = await prepare_key_update_data(data=data, existing_key_row=existing_key)
assert result["metadata"]["service_account_id"] == "sa-123"
assert result["metadata"]["other"] == "kept"
@pytest.mark.asyncio
async def test_prepare_key_update_data_duration_never_expires():
"""Test that duration="-1" sets expires to None (never expires)."""
@ -8007,6 +8132,7 @@ async def test_update_key_non_budget_fields_allowed_for_internal_user(monkeypatc
mock_existing_key.max_budget = 10.0
mock_existing_key.key_alias = None
mock_existing_key.models = []
mock_existing_key.metadata = {}
mock_existing_key.model_dump.return_value = {
"token": test_hashed_token,
"user_id": "internal_user",
@ -8089,9 +8215,7 @@ async def test_update_key_non_budget_rejects_cross_user_modification(monkeypatch
)
mock_prisma_client = AsyncMock()
test_hashed_token = (
"cafebabe" * 8
)
test_hashed_token = "cafebabe" * 8
mock_existing_key = MagicMock()
mock_existing_key.token = test_hashed_token