Skip to content

Commit 529c049

Browse files
authored
fix: copy details dict in Usage.__add__ to prevent mutation of or… (#4606)
1 parent 8e0f4ef commit 529c049

File tree

2 files changed

+53
-0
lines changed

2 files changed

+53
-0
lines changed

pydantic_ai_slim/pydantic_ai/usage.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ class UsageBase:
5050
] = dataclasses.field(default_factory=dict[str, int])
5151
"""Any extra details returned by the model."""
5252

53+
def __copy__(self) -> UsageBase:
54+
"""Shallow copy that also copies mutable fields like `details`."""
55+
cls = type(self)
56+
new = cls.__new__(cls)
57+
new.__dict__.update(self.__dict__)
58+
new.details = self.details.copy()
59+
return new
60+
5361
@property
5462
@deprecated('`request_tokens` is deprecated, use `input_tokens` instead')
5563
def request_tokens(self) -> int:

tests/test_usage_limits.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,51 @@ def test_add_usages_with_none_detail_value():
408408
)
409409

410410

411+
def test_add_request_usages_does_not_mutate_original():
412+
"""Test that __add__ does not mutate the original object's details dict (issue #4605)."""
413+
u1 = RequestUsage(input_tokens=10, details={'reasoning_tokens': 5})
414+
u2 = RequestUsage(input_tokens=20, details={'reasoning_tokens': 3})
415+
416+
result = u1 + u2
417+
418+
# The result should have the summed details
419+
assert result.details == {'reasoning_tokens': 8}
420+
# The original must NOT be mutated
421+
assert u1.details == {'reasoning_tokens': 5}
422+
# They must be independent dict objects
423+
assert u1.details is not result.details
424+
425+
426+
def test_add_run_usages_does_not_mutate_original():
427+
"""Test that __add__ does not mutate the original object's details dict (issue #4605)."""
428+
r1 = RunUsage(requests=1, input_tokens=10, details={'reasoning_tokens': 50})
429+
r2 = RunUsage(requests=1, input_tokens=20, details={'reasoning_tokens': 30})
430+
431+
result = r1 + r2
432+
433+
assert result.details == {'reasoning_tokens': 80}
434+
assert r1.details == {'reasoning_tokens': 50}
435+
assert r1.details is not result.details
436+
437+
438+
def test_add_usage_repeated_calls_stable():
439+
"""Test that repeated __add__ calls return consistent results (issue #4605).
440+
441+
This simulates AgentStream.usage() at result.py:169 being called multiple times:
442+
return self._initial_run_ctx_usage + self._raw_stream_response.usage()
443+
"""
444+
initial = RunUsage(requests=1, input_tokens=500, details={})
445+
stream = RequestUsage(input_tokens=500, output_tokens=200, details={'reasoning_tokens': 150})
446+
447+
results = [initial + stream for _ in range(3)]
448+
449+
# All calls must return the same values
450+
for r in results:
451+
assert r.details == {'reasoning_tokens': 150}
452+
# The initial usage must remain unchanged
453+
assert initial.details == {}
454+
455+
411456
async def test_tool_call_limit() -> None:
412457
test_agent = Agent(TestModel())
413458

0 commit comments

Comments
 (0)