@@ -70,13 +70,39 @@ def test_none_model(self):
7070 turn_usage = pydantic_ai_usage_to_turn_usage (usage , model = None )
7171 assert turn_usage .model is None
7272
73- def test_empty_usage_produces_zero_counts (self ):
74- """An empty RunUsage maps to 0 counts and None tokens."""
73+ def test_all_zero_usage_preserves_real_zeros (self ):
74+ """An all-zero RunUsage maps real 0s through (not None).
75+
76+ RunUsage token fields are ints defaulting to 0. A 0 is a genuine
77+ value (e.g. a cache-hit with 0 output tokens), not "unknown", so it
78+ must survive normalization as 0 rather than being coerced to None.
79+ """
7580 usage = RunUsage ()
7681 turn_usage = pydantic_ai_usage_to_turn_usage (usage , model = "openai:gpt-4o" )
7782 assert turn_usage .num_llm_calls == 0
78- assert turn_usage .input_tokens is None
83+ assert turn_usage .input_tokens == 0
84+ assert turn_usage .output_tokens == 0
85+ assert turn_usage .cached_input_tokens == 0
86+ assert turn_usage .total_tokens == 0
87+
88+ def test_missing_field_degrades_to_none (self ):
89+ """A usage object MISSING a field maps that field to None (defensive getattr).
90+
91+ Guards the version-rename guarantee: if pydantic-ai renames a field,
92+ the absent attribute degrades to None rather than raising.
93+ """
94+
95+ class StubUsage :
96+ requests = 2
97+ input_tokens = 100
98+ # no output_tokens / cache_read_tokens / total_tokens attributes
99+
100+ turn_usage = pydantic_ai_usage_to_turn_usage (StubUsage (), model = "openai:gpt-4o" )
101+ assert turn_usage .num_llm_calls == 2
102+ assert turn_usage .input_tokens == 100
79103 assert turn_usage .output_tokens is None
104+ assert turn_usage .cached_input_tokens is None
105+ assert turn_usage .total_tokens is None
80106
81107
82108class TestPydanticAITurn :
@@ -149,6 +175,46 @@ async def test_events_match_bare_converter(self):
149175 assert type (a ) is type (b )
150176 assert a .model_dump () == b .model_dump ()
151177
178+ async def test_usage_captured_via_real_usage_accessor (self ):
179+ """Drive the turn through the REAL ``result.usage`` property accessor.
180+
181+ The production code reads ``getattr(run_result, "usage", None)``, which
182+ on this pydantic-ai version resolves the ``_DeprecatedCallableRunUsage``
183+ property (NOT ``_state.usage`` directly). This asserts that the real
184+ accessor path the converter uses captures the run usage. Constructing
185+ the event without our test's ``_state`` shortcut: we set ``_state.usage``
186+ only because that is the sole supported way to seed an
187+ ``AgentRunResult``, but we then assert capture happens through the
188+ public ``.usage`` attribute access (verified below).
189+ """
190+ known_usage = RunUsage (requests = 4 , input_tokens = 512 , output_tokens = 64 )
191+ result = AgentRunResult (output = "done" , _output_tool_name = None )
192+ result ._state .usage = known_usage
193+ result_event = AgentRunResultEvent (result = result )
194+
195+ # Sanity: the value is reachable via the real public accessor the
196+ # production code uses (not just via the private _state). The
197+ # _DeprecatedCallableRunUsage property wraps the value, so compare by
198+ # equality rather than identity.
199+ accessed = getattr (result_event .result , "usage" , None )
200+ assert accessed is not None
201+ assert accessed .input_tokens == 512
202+ assert accessed .requests == 4
203+
204+ events = [
205+ PartStartEvent (index = 0 , part = TextPart (content = "" )),
206+ PartEndEvent (index = 0 , part = TextPart (content = "" )),
207+ result_event ,
208+ ]
209+ turn = PydanticAITurn (_aiter (events ), model = "anthropic:claude-3-5-sonnet" )
210+ await _collect (turn .events )
211+
212+ usage = turn .usage ()
213+ assert usage .model == "anthropic:claude-3-5-sonnet"
214+ assert usage .input_tokens == 512
215+ assert usage .output_tokens == 64
216+ assert usage .num_llm_calls == 4
217+
152218 async def test_no_usage_event_leaves_default_usage (self ):
153219 """If the stream has no AgentRunResultEvent, usage() returns the default (tokens None)."""
154220 events = [
0 commit comments