From 8e78990f01da32039c62068c531714865e535bb1 Mon Sep 17 00:00:00 2001 From: JingHao-Leon <102573344+JingHao-Leon@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:57:41 +0800 Subject: [PATCH] fix(types): safe attribute access on PromptTokensDetailsWrapper deleted fields __init__ deletes None-valued cache fields from the instance dict for payload hygiene, but pydantic v2 then raises AttributeError on direct reads of those deleted fields (issue #43756: first-turn responses with no cache activity crash downstream consumers that read wrapper.cache_creation_tokens directly). Add a __getattr__ fallback on the wrapper returning the declared class default for deleted fields, keeping payload hygiene (model_dump still excludes them) while eliminating the crash. The old hasattr-based expectations in test_prompt_tokens_details_no_cache_write_tokens_when_absent are updated to the new direct-read contract, plus regression tests for both deleted-field reads and dump exclusion. Fixes #43756 --- litellm/types/utils.py | 17 +++++++++++++++++ tests/unit/test_utils.py | 37 ++++++++++++++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index dba15bc99a5..257b228e59c 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -1827,6 +1827,23 @@ class PromptTokensDetailsWrapper( if self.cached_tokens_details is None: del self.cached_tokens_details + def __getattr__(self, name: str): + """Return declared defaults for payload-hygiene deletions (issue #43756). + + __init__ deletes None-valued fields from the instance dict so they stay + out of serialized payloads; pydantic v2 then raises on direct attribute + access to those deleted fields. This fallback returns the declared + class default instead, so downstream direct reads + (e.g. wrapper.cache_creation_tokens on a first-turn response with no + cache activity) behave like a zero-valued field rather than crashing. + """ + field_info = type(self).model_fields.get(name) + if field_info is not None: + return field_info.get_default() + raise AttributeError( + f"{type(self).__name__!s} object has no attribute {name!r}" + ) + class ServerToolUse(BaseModel): web_search_requests: int | None = None diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 2c612aa350c..469d3ac09dc 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -154,11 +154,16 @@ def test_usage_anthropic_cache_creation_maps_to_cache_write_tokens(): def test_prompt_tokens_details_no_cache_write_tokens_when_absent(): - """A read-only cache hit (no cache write) must not surface cache-write fields.""" + """A read-only cache hit (no cache write) must not surface cache-write fields + in the serialized payload; direct reads of deleted fields return the declared + defaults instead of raising AttributeError (issue #43756).""" details = PromptTokensDetailsWrapper(cached_tokens=800) assert details.cached_tokens == 800 - assert not hasattr(details, "cache_write_tokens") - assert not hasattr(details, "cache_creation_tokens") + assert details.cache_write_tokens is None + assert details.cache_creation_tokens is None + dumped = details.model_dump() + assert "cache_write_tokens" not in dumped + assert "cache_creation_tokens" not in dumped def test_prompt_tokens_details_cache_write_creation_stay_in_sync_on_assignment(): @@ -174,6 +179,32 @@ def test_prompt_tokens_details_cache_write_creation_stay_in_sync_on_assignment() assert details.cache_write_tokens == details.cache_creation_tokens == 375 +def test_prompt_tokens_details_none_cache_fields_read_as_none(): + """Direct reads of deleted None-valued fields must return the declared + defaults instead of raising AttributeError (issue #43756).""" + wrapper = PromptTokensDetailsWrapper( + prompt_tokens=100, + cache_creation_tokens=None, + cache_read_input_tokens=None, + cache_write_tokens=None, + ) + assert wrapper.cache_creation_tokens is None + assert wrapper.cache_write_tokens is None + + +def test_prompt_tokens_details_dump_excludes_deleted_none_fields(): + """Payload hygiene survives: deleted None fields stay out of model_dump.""" + wrapper = PromptTokensDetailsWrapper( + prompt_tokens=100, + cache_creation_tokens=None, + cache_read_input_tokens=None, + cache_write_tokens=None, + ) + dumped = wrapper.model_dump() + assert "cache_write_tokens" not in dumped + assert "cache_creation_tokens" not in dumped + + def test_potential_model_names_keeps_provider_prefixed_candidate(): """A provider whose own model ids repeat the litellm provider name (Perplexity's Agent API serves `perplexity/glm-5.2`, mapped as `perplexity/perplexity/glm-5.2`)