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

  1. Omit service_account_id from the metadata payload on update -- LiteLLM preserves the existing value automatically
  2. Keep the exact same value if you must include it (no-op writes pass the equality check)
  3. To change a key's service account, delete the key and generate a new one with the desired service_account_id
  4. 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

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


AI-assisted analysis of BerriAI/litellm@77b7c6c40c (2026-08-18). Data as JSON: /api/errors/faec4d779c48d88b. Report an issue: GitHub.