{"record":{"id":"faec4d779c48d88b","repo":"BerriAI/litellm","slug":"reserved-field-is-immutable-once-set-and-cannot","errorCode":null,"errorMessage":"{reserved_field} is immutable once set and cannot be changed via update.","messagePattern":"(.+?) is immutable once set and cannot be changed via update\\.","errorType":"http","errorClass":"HTTPException","httpStatus":400,"severity":"error","filePath":"litellm/proxy/management_endpoints/key_management_endpoints.py","lineNumber":1968,"sourceCode":"def prepare_metadata_fields(data: BaseModel, non_default_values: dict, existing_metadata: dict) -> dict:\n    \"\"\"\n    Check LiteLLM_ManagementEndpoint_MetadataFields (proxy/_types.py) for fields that are allowed to be updated\n    \"\"\"\n    if \"metadata\" not in non_default_values:  # allow user to set metadata to none\n        non_default_values[\"metadata\"] = existing_metadata.copy()\n\n    casted_metadata: Final = cast(dict, non_default_values[\"metadata\"])\n\n    # Reserved metadata fields are immutable once set. Preserve the existing value\n    # when omitted, reject any explicit attempt to change it (including null).\n    for reserved_field in LiteLLM_Reserved_Metadata_Fields:\n        existing_value = existing_metadata.get(reserved_field)\n        if existing_value is None:\n            continue\n        if casted_metadata is None or (\n            reserved_field in casted_metadata and casted_metadata[reserved_field] != existing_value\n        ):\n            raise HTTPException(\n                status_code=400,\n                detail=f\"{reserved_field} is immutable once set and cannot be changed via update.\",\n            )\n        casted_metadata[reserved_field] = existing_value\n\n    data_json: Final[Mapping[str, object]] = data.model_dump(exclude_unset=True, exclude_none=True)\n\n    try:\n        for k, v in data_json.items():\n            if k in LiteLLM_ManagementEndpoint_MetadataFields:\n                if isinstance(v, datetime):\n                    casted_metadata[k] = v.isoformat()\n                else:\n                    casted_metadata[k] = v\n            if k in LiteLLM_ManagementEndpoint_MetadataFields_Premium:\n                from litellm.proxy.utils import _premium_user_check\n\n                if v:","sourceCodeStart":1950,"sourceCodeEnd":1986,"githubUrl":"https://github.com/BerriAI/litellm/blob/77b7c6c40c0c5aa5fbcb1d6a1825ac39ca8829b8/litellm/proxy/management_endpoints/key_management_endpoints.py#L1950-L1986","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"# before\nawait client.post(\"/key/update\", json={\"key\": k, \"metadata\": {**old_metadata, \"service_account_id\": None}})\n\n# after\nmetadata = {k: v for k, v in old_metadata.items() if k != \"service_account_id\"}\nawait client.post(\"/key/update\", json={\"key\": k, \"metadata\": metadata})","handlingStrategy":"validation","validationCode":"RESERVED = {\"service_account_id\"}  # LiteLLM_Reserved_Metadata_Fields\n\nmetadata = {k: v for k, v in incoming_metadata.items() if k not in RESERVED}\n# safe to send: omitted reserved fields are auto-preserved server-side","typeGuard":"def strips_reserved(md: dict | None) -> dict | None:\n    if md is None:\n        return None\n    reserved = {\"service_account_id\"}\n    return {k: v for k, v in md.items() if k not in reserved}","tryCatchPattern":"try:\n    r = await client.post(\"/key/update\", json=payload)\nexcept httpx.HTTPStatusError as e:\n    if e.response.status_code == 400 and \"immutable\" in e.response.text:\n        payload[\"metadata\"] = strips_reserved(payload.get(\"metadata\"))\n        r = await client.post(\"/key/update\", json=payload)\n    else:\n        raise","preventionTips":["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"],"tags":["litellm","proxy","metadata","immutable-field","service-account","validation"],"backgroundTag":"immutable-field-violation","analyzedSha":"77b7c6c40c0c5aa5fbcb1d6a1825ac39ca8829b8","analyzedAt":"2026-08-18T11:44:31.656Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}