perf(core): fast-path SafeAttributeModel.__delattr__ for declared fields (#33993)

Continues #29762. Response models (Message, Choices, Usage) delete unset
optional fields in __init__ so model_dump matches the OpenAI spec. Each delete
routed through pydantic's BaseModel.__delattr__, whose per-call
ModelMetaclass.__getattr__ lookup and _check_frozen dominate construction. When
the target is a declared field already present in __dict__ on a non-frozen
model, delete it with object.__delattr__ directly; that is exactly what pydantic
2.13 does for that case, minus the metaclass getattr and the frozen check. It
falls back to the previous super().__delattr__ path for extras, private
attributes, cached properties and missing names, so behavior is unchanged.

Co-authored-by: Jay Gowdy <jgowdy@godaddy.com>
This commit is contained in:
Yassin Kortam 2026-07-20 17:06:56 -07:00 committed by GitHub
parent ffe56cf5a2
commit 2c99b7804e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 110 additions and 0 deletions

View file

@ -94,7 +94,17 @@ class SafeAttributeModel:
"""
def __delattr__(self, name):
# Dropping an unset optional field stored in __dict__ goes straight to
# object.__delattr__, skipping pydantic's __delattr__ whose per-call
# class getattr lookup and _check_frozen dominate response construction.
try:
if (
name in type(self).__pydantic_fields__
and name in self.__dict__
and not type(self).model_config.get("frozen")
):
object.__delattr__(self, name)
return
super().__delattr__(name)
except AttributeError:
# noop if attribute does not exist

View file

@ -526,3 +526,103 @@ def test_delta_serialization_contract():
keys = list(extra_dump.keys())
assert extra_dump["custom_field"] == "v"
assert keys.index("custom_field") < keys.index("content")
def test_safe_attribute_model_delattr():
"""
SafeAttributeModel.__delattr__ must remove a field from the instance so it
is omitted from model_dump (OpenAI spec), whether the field is a declared
model field or an extra, and deleting a missing attribute must be a no-op.
"""
from litellm.types.utils import Message
# Unset optional declared fields are dropped during __init__ -> absent from dump
msg = Message(content="hi", role="assistant")
assert not hasattr(msg, "audio")
assert not hasattr(msg, "reasoning_content")
assert "audio" not in msg.model_dump()
assert "reasoning_content" not in msg.model_dump()
# Explicitly deleting a present declared field removes it from the dump
msg2 = Message(content="hi", role="assistant", reasoning_content="because")
assert msg2.reasoning_content == "because"
del msg2.reasoning_content
assert not hasattr(msg2, "reasoning_content")
assert "reasoning_content" not in msg2.model_dump()
# Extra fields (extra='allow') are still deletable via the fallback path
msg3 = Message(content="hi", role="assistant", custom_field=123)
assert msg3.custom_field == 123
del msg3.custom_field
assert not hasattr(msg3, "custom_field")
assert "custom_field" not in msg3.model_dump()
# Deleting a non-existent attribute is a silent no-op
msg4 = Message(content="hi", role="assistant")
del msg4.does_not_exist
def test_delattr_fast_path_matches_pydantic_exactly():
"""
The fast path must be observationally identical to pydantic's own
__delattr__ for a declared field, including model_fields_set membership and
the exclude_unset dump, both of which the fast path never touches. Deleting
the same field through the fast path and through pydantic's __delattr__
(reached by skipping SafeAttributeModel in the MRO) must leave identical
state, so if a future pydantic release makes __delattr__ mutate
__pydantic_fields_set__ the two diverge and this fails rather than silently
shifting the serialization contract.
"""
from litellm.types.utils import Message, SafeAttributeModel
def observe(m: Message) -> tuple:
return (
hasattr(m, "reasoning_content"),
"reasoning_content" in m.model_fields_set,
"reasoning_content" in m.model_dump(),
"reasoning_content" in m.model_dump(exclude_unset=True),
)
fast = Message(content="hi", role="assistant", reasoning_content="x")
del fast.reasoning_content
control = Message(content="hi", role="assistant", reasoning_content="x")
super(SafeAttributeModel, control).__delattr__("reasoning_content")
assert observe(fast) == observe(control)
# A deleted field is gone from __dict__ (so absent from both dumps) yet
# stays in model_fields_set, since neither delete path clears fields_set.
assert observe(fast) == (False, True, False, False)
def test_delattr_fast_path_missing_attribute_is_noop():
"""
The declared-field fast path must stay a silent no-op when the object delete
fails: the field passes the __dict__ membership guard but is already gone by
the time object.__delattr__ runs. This models a concurrent removal of the same
field on a shared response object. Previously the fast-path delete ran outside
the AttributeError handler, so the error leaked onto the Message/Delta/Choices/
Usage construction hot path instead of being swallowed like the slow path.
_VanishingDict reports every key as present (passing the guard) while storing
nothing, so the real object.__delattr__ still raises AttributeError.
"""
from litellm.types.utils import SafeAttributeModel
class _VanishingDict(dict):
def __contains__(self, key: object) -> bool:
return True
class _RacyModel(SafeAttributeModel):
__pydantic_fields__ = {"x": object()}
model_config: dict = {}
def __init__(self) -> None:
self.__dict__ = _VanishingDict()
racy = _RacyModel()
assert "x" in racy.__dict__
assert "x" not in dict.keys(racy.__dict__)
del racy.x
del racy.x