BerriAI/litellm · error · HTTPException
{reserved_field} is immutable once set and cannot be changed
Error message
{reserved_field} is immutable once set and cannot be changed via update. What it means
During key update (prepare_metadata_fields), LiteLLM protects reserved metadata fields -- currently only 'service_account_id' (LiteLLM_Reserved_Metadata_Fields in litellm/proxy/_types.py) -- as immutable once set: updates that omit the field keep the stored value, but any explicit attempt to change it, including sending null, fails with HTTP 400. This prevents breaking the linkage between a key and its service account, which other authorization logic depends on.
Source
Thrown at litellm/proxy/management_endpoints/key_management_endpoints.py:1968
def prepare_metadata_fields(data: BaseModel, non_default_values: dict, existing_metadata: dict) -> dict:
"""
Check LiteLLM_ManagementEndpoint_MetadataFields (proxy/_types.py) for fields that are allowed to be updated
"""
if "metadata" not in non_default_values: # allow user to set metadata to none
non_default_values["metadata"] = existing_metadata.copy()
casted_metadata: Final = 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: Final[Mapping[str, object]] = data.model_dump(exclude_unset=True, exclude_none=True)
try:
for k, v in data_json.items():
if k in LiteLLM_ManagementEndpoint_MetadataFields:
if isinstance(v, datetime):
casted_metadata[k] = v.isoformat()
else:
casted_metadata[k] = v
if k in LiteLLM_ManagementEndpoint_MetadataFields_Premium:
from litellm.proxy.utils import _premium_user_check
if v:View on GitHub (pinned to 77b7c6c40c)
Solutions
- Omit service_account_id from the metadata payload on update -- LiteLLM preserves the existing value automatically
- Keep the exact same value if you must include it (no-op writes pass the equality check)
- To change a key's service account, delete the key and generate a new one with the desired service_account_id
- Filter reserved keys out of metadata before sending: strip {'service_account_id'} client-side
Example fix
# before
await client.post("/key/update", json={"key": k, "metadata": {**old_metadata, "service_account_id": None}})
# after
metadata = {k: v for k, v in old_metadata.items() if k != "service_account_id"}
await client.post("/key/update", json={"key": k, "metadata": metadata}) Defensive patterns
Strategy: validation
Validate before calling
RESERVED = {"service_account_id"} # LiteLLM_Reserved_Metadata_Fields
metadata = {k: v for k, v in incoming_metadata.items() if k not in RESERVED}
# safe to send: omitted reserved fields are auto-preserved server-side Type guard
def strips_reserved(md: dict | None) -> dict | None:
if md is None:
return None
reserved = {"service_account_id"}
return {k: v for k, v in md.items() if k not in reserved} Try / catch
try:
r = await client.post("/key/update", json=payload)
except httpx.HTTPStatusError as e:
if e.response.status_code == 400 and "immutable" in e.response.text:
payload["metadata"] = strips_reserved(payload.get("metadata"))
r = await client.post("/key/update", json=payload)
else:
raise Prevention
- Never round-trip full metadata objects from GET /key/info into /key/update without filtering reserved keys
- Treat service_account_id as read-only; re-parenting requires deleting and regenerating the key
- Sync your client's reserved-field list with LiteLLM_Reserved_Metadata_Fields when upgrading
When it happens
Trigger: POST /key/update with metadata: {"service_account_id": "<different-id>"} on a key that already has one; setting metadata.service_account_id to null to 'clear' it; a generic metadata-merge client that always resends all metadata keys with new values; PUT-style tooling that round-trips GET /key/info output back into /key/update after editing unrelated fields.
Common situations: Automation that copies metadata from one key to another (cloning) drags service_account_id along; attempts to re-parent a service-account key to a new service account via update; cleanup scripts nulling unknown metadata keys; frontend forms that submit the full metadata object with blanked fields.
Related errors
- soft_budget must be a non-negative finite number. Received:
- max_budget must be a non-negative finite number. Received: {
- either key or key_alias must be provided
- max_budget cannot be negative. Received: {data.max_budget}
- soft_budget cannot be negative. Received: {data.soft_budget}
AI-assisted analysis of BerriAI/litellm@77b7c6c40c (2026-08-18).
Data as JSON: /api/errors/faec4d779c48d88b.
Report an issue: GitHub.