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
This commit is contained in:
JingHao-Leon 2026-09-30 03:57:41 +08:00
parent 118ce3cc91
commit 8e78990f01
2 changed files with 51 additions and 3 deletions

View file

@ -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

View file

@ -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`)