Skip to content

Commit fe5ada5

Browse files
declan-scaleclaude
andcommitted
fix(pydantic-ai): preserve real zero token counts in usage normalization + cover real usage accessor
Pass getattr results straight through so a real 0 (e.g. a cache-hit with 0 output tokens) stays 0 while a MISSING attribute still degrades to None. Previously `x if x else None` coerced legitimate zeros to None. Adds tests for the 0->0 mapping, the missing-field->None defensive guarantee, and the real result.usage property accessor path the converter uses. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent cae557a commit fe5ada5

2 files changed

Lines changed: 77 additions & 7 deletions

File tree

src/agentex/lib/adk/_modules/_pydantic_ai_turn.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ def pydantic_ai_usage_to_turn_usage(usage: Any, model: str | None) -> TurnUsage:
4949
output_tokens -> output_tokens
5050
cache_read_tokens -> cached_input_tokens
5151
total_tokens -> total_tokens
52+
53+
getattr results pass straight through: a MISSING attribute degrades to
54+
None (defensive), while a real 0 stays 0 (a cache-hit with 0 output
55+
tokens is a genuine zero, not "unknown") and a real N stays N.
5256
"""
5357
raw_input = getattr(usage, "input_tokens", None)
5458
raw_output = getattr(usage, "output_tokens", None)
@@ -58,10 +62,10 @@ def pydantic_ai_usage_to_turn_usage(usage: Any, model: str | None) -> TurnUsage:
5862

5963
return TurnUsage(
6064
model=model,
61-
input_tokens=raw_input if raw_input else None,
62-
output_tokens=raw_output if raw_output else None,
63-
cached_input_tokens=raw_cache_read if raw_cache_read else None,
64-
total_tokens=raw_total if raw_total else None,
65+
input_tokens=raw_input,
66+
output_tokens=raw_output,
67+
cached_input_tokens=raw_cache_read,
68+
total_tokens=raw_total,
6569
num_llm_calls=raw_requests if raw_requests is not None else 0,
6670
)
6771

tests/lib/adk/test_pydantic_ai_turn.py

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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

82108
class 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

Comments
 (0)