from __future__ import annotations import pytest from openai.types.completion_usage import CompletionTokensDetails, PromptTokensDetails from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails from agents import Agent, Runner from agents.run_internal.agent_runner_helpers import snapshot_usage, usage_delta from agents.usage import ( RequestUsage, Usage, deserialize_usage, model_usage_to_span_usage, serialize_usage, ) from tests.fake_model import FakeModel from tests.test_responses import get_text_message def test_usage_defaults_cache_write_tokens_to_zero() -> None: usage = Usage() assert usage.input_tokens_details.cached_tokens == 0 assert getattr(usage.input_tokens_details, "cache_write_tokens", None) == 0 @pytest.mark.asyncio async def test_runner_run_carries_request_usage_entries() -> None: """Ensure usage produced by the model propagates to RunResult context.""" usage = Usage( requests=1, input_tokens=10, output_tokens=5, total_tokens=15, request_usage_entries=[ RequestUsage( input_tokens=10, output_tokens=5, total_tokens=15, input_tokens_details=InputTokensDetails.model_validate( {"cache_write_tokens": 0, "cached_tokens": 0} ), output_tokens_details=OutputTokensDetails(reasoning_tokens=0), ) ], ) model = FakeModel(initial_output=[get_text_message("done")]) model.set_hardcoded_usage(usage) agent = Agent(name="usage-agent", model=model) result = await Runner.run(agent, input="hi") propagated = result.context_wrapper.usage assert propagated.requests == 1 assert propagated.total_tokens == 15 assert len(propagated.request_usage_entries) == 1 entry = propagated.request_usage_entries[0] assert entry.input_tokens == 10 assert entry.output_tokens == 5 assert entry.total_tokens == 15 def test_usage_add_aggregates_all_fields(): u1 = Usage( requests=1, input_tokens=10, input_tokens_details=InputTokensDetails.model_validate( {"cache_write_tokens": 5, "cached_tokens": 3} ), output_tokens=20, output_tokens_details=OutputTokensDetails(reasoning_tokens=5), total_tokens=30, ) u2 = Usage( requests=2, input_tokens=7, input_tokens_details=InputTokensDetails.model_validate( {"cache_write_tokens": 6, "cached_tokens": 4} ), output_tokens=8, output_tokens_details=OutputTokensDetails(reasoning_tokens=6), total_tokens=15, ) u1.add(u2) assert u1.requests == 3 assert u1.input_tokens == 17 assert u1.output_tokens == 28 assert u1.total_tokens == 45 assert u1.input_tokens_details.cached_tokens == 7 assert getattr(u1.input_tokens_details, "cache_write_tokens", None) == 11 assert u1.output_tokens_details.reasoning_tokens == 11 def test_usage_add_aggregates_with_none_values(): u1 = Usage() u2 = Usage( requests=2, input_tokens=7, input_tokens_details=InputTokensDetails.model_validate( {"cache_write_tokens": 0, "cached_tokens": 4} ), output_tokens=8, output_tokens_details=OutputTokensDetails(reasoning_tokens=6), total_tokens=15, ) u1.add(u2) assert u1.requests == 2 assert u1.input_tokens == 7 assert u1.output_tokens == 8 assert u1.total_tokens == 15 assert u1.input_tokens_details.cached_tokens == 4 assert u1.output_tokens_details.reasoning_tokens == 6 def test_request_usage_creation(): """Test that RequestUsage is created correctly.""" request_usage = RequestUsage( input_tokens=100, output_tokens=200, total_tokens=300, input_tokens_details=InputTokensDetails.model_validate( {"cache_write_tokens": 0, "cached_tokens": 10} ), output_tokens_details=OutputTokensDetails(reasoning_tokens=20), ) assert request_usage.input_tokens == 100 assert request_usage.output_tokens == 200 assert request_usage.total_tokens == 300 assert request_usage.input_tokens_details.cached_tokens == 10 assert request_usage.output_tokens_details.reasoning_tokens == 20 def test_usage_add_preserves_single_request(): """Test that adding a single request Usage creates an RequestUsage entry.""" u1 = Usage() u2 = Usage( requests=1, input_tokens=100, input_tokens_details=InputTokensDetails.model_validate( {"cache_write_tokens": 0, "cached_tokens": 10} ), output_tokens=200, output_tokens_details=OutputTokensDetails(reasoning_tokens=20), total_tokens=300, ) u1.add(u2) # Should preserve the request usage details assert len(u1.request_usage_entries) == 1 request_usage = u1.request_usage_entries[0] assert request_usage.input_tokens == 100 assert request_usage.output_tokens == 200 assert request_usage.total_tokens == 300 assert request_usage.input_tokens_details.cached_tokens == 10 assert request_usage.output_tokens_details.reasoning_tokens == 20 def test_usage_add_ignores_zero_token_requests(): """Test that zero-token requests don't create request_usage_entries.""" u1 = Usage() u2 = Usage( requests=1, input_tokens=0, input_tokens_details=InputTokensDetails.model_validate( {"cache_write_tokens": 0, "cached_tokens": 0} ), output_tokens=0, output_tokens_details=OutputTokensDetails(reasoning_tokens=0), total_tokens=0, ) u1.add(u2) # Should not create a request_usage_entry for zero tokens assert len(u1.request_usage_entries) == 0 def test_usage_add_ignores_multi_request_usage(): """Test that multi-request Usage objects don't create request_usage_entries.""" u1 = Usage() u2 = Usage( requests=3, # Multiple requests input_tokens=100, input_tokens_details=InputTokensDetails.model_validate( {"cache_write_tokens": 0, "cached_tokens": 10} ), output_tokens=200, output_tokens_details=OutputTokensDetails(reasoning_tokens=20), total_tokens=300, ) u1.add(u2) # Should not create a request usage entry for multi-request usage assert len(u1.request_usage_entries) == 0 def test_usage_add_merges_existing_request_usage_entries(): """Test that existing request_usage_entries are merged when adding Usage objects.""" # Create first usage with request_usage_entries u1 = Usage() u2 = Usage( requests=1, input_tokens=100, input_tokens_details=InputTokensDetails.model_validate( {"cache_write_tokens": 0, "cached_tokens": 10} ), output_tokens=200, output_tokens_details=OutputTokensDetails(reasoning_tokens=20), total_tokens=300, ) u1.add(u2) # Create second usage with request_usage_entries u3 = Usage( requests=1, input_tokens=50, input_tokens_details=InputTokensDetails.model_validate( {"cache_write_tokens": 0, "cached_tokens": 5} ), output_tokens=75, output_tokens_details=OutputTokensDetails(reasoning_tokens=10), total_tokens=125, ) u1.add(u3) # Should have both request_usage_entries assert len(u1.request_usage_entries) == 2 # First request first = u1.request_usage_entries[0] assert first.input_tokens == 100 assert first.output_tokens == 200 assert first.total_tokens == 300 # Second request second = u1.request_usage_entries[1] assert second.input_tokens == 50 assert second.output_tokens == 75 assert second.total_tokens == 125 def test_usage_add_with_pre_existing_request_usage_entries(): """Test adding Usage objects that already have request_usage_entries.""" u1 = Usage() # Create a usage with request_usage_entries u2 = Usage( requests=1, input_tokens=100, input_tokens_details=InputTokensDetails.model_validate( {"cache_write_tokens": 0, "cached_tokens": 10} ), output_tokens=200, output_tokens_details=OutputTokensDetails(reasoning_tokens=20), total_tokens=300, ) u1.add(u2) # Create another usage with request_usage_entries u3 = Usage( requests=1, input_tokens=50, input_tokens_details=InputTokensDetails.model_validate( {"cache_write_tokens": 0, "cached_tokens": 5} ), output_tokens=75, output_tokens_details=OutputTokensDetails(reasoning_tokens=10), total_tokens=125, ) # Add u3 to u1 u1.add(u3) # Should have both request_usage_entries assert len(u1.request_usage_entries) == 2 assert u1.request_usage_entries[0].input_tokens == 100 assert u1.request_usage_entries[1].input_tokens == 50 def test_usage_add_preserves_existing_entries_when_top_level_also_set(): """When `other` has both top-level single-request fields AND pre-populated `request_usage_entries`, the existing entries (which carry the authoritative nested token details) must not be discarded in favor of a synthesized entry built from only the top-level fields. """ u1 = Usage() u2 = Usage( requests=1, input_tokens=100, output_tokens=50, total_tokens=150, request_usage_entries=[ RequestUsage( input_tokens=100, output_tokens=50, total_tokens=150, input_tokens_details=InputTokensDetails.model_validate( {"cache_write_tokens": 0, "cached_tokens": 10} ), output_tokens_details=OutputTokensDetails(reasoning_tokens=5), ) ], ) u1.add(u2) # The pre-populated entry must be preserved — including its nested details — # rather than being replaced by a synthesized entry with zeroed-out details. assert len(u1.request_usage_entries) == 1 entry = u1.request_usage_entries[0] assert entry.input_tokens_details.cached_tokens == 10 assert entry.output_tokens_details.reasoning_tokens == 5 def test_usage_request_usage_entries_default_empty(): """Test that request_usage_entries defaults to an empty list.""" u = Usage() assert u.request_usage_entries == [] def test_anthropic_cost_calculation_scenario(): """Test a realistic scenario for Sonnet 4.5 cost calculation with 200K token thresholds.""" # Simulate 3 API calls: 100K, 150K, and 80K input tokens each # None exceed 200K, so they should all use the lower pricing tier usage = Usage() # First request: 100K input tokens req1 = Usage( requests=1, input_tokens=100_000, input_tokens_details=InputTokensDetails.model_validate( {"cache_write_tokens": 0, "cached_tokens": 0} ), output_tokens=50_000, output_tokens_details=OutputTokensDetails(reasoning_tokens=0), total_tokens=150_000, ) usage.add(req1) # Second request: 150K input tokens req2 = Usage( requests=1, input_tokens=150_000, input_tokens_details=InputTokensDetails.model_validate( {"cache_write_tokens": 0, "cached_tokens": 0} ), output_tokens=75_000, output_tokens_details=OutputTokensDetails(reasoning_tokens=0), total_tokens=225_000, ) usage.add(req2) # Third request: 80K input tokens req3 = Usage( requests=1, input_tokens=80_000, input_tokens_details=InputTokensDetails.model_validate( {"cache_write_tokens": 0, "cached_tokens": 0} ), output_tokens=40_000, output_tokens_details=OutputTokensDetails(reasoning_tokens=0), total_tokens=120_000, ) usage.add(req3) # Verify aggregated totals assert usage.requests == 3 assert usage.input_tokens == 330_000 # 100K + 150K + 80K assert usage.output_tokens == 165_000 # 50K + 75K + 40K assert usage.total_tokens == 495_000 # 150K + 225K + 120K # Verify request_usage_entries preservation assert len(usage.request_usage_entries) == 3 assert usage.request_usage_entries[0].input_tokens == 100_000 assert usage.request_usage_entries[1].input_tokens == 150_000 assert usage.request_usage_entries[2].input_tokens == 80_000 # All request_usage_entries are under 200K threshold for req in usage.request_usage_entries: assert req.input_tokens < 200_000 assert req.output_tokens < 200_000 def test_usage_normalizes_none_token_details(): # Some providers don't populate optional token detail fields # (cached_tokens, reasoning_tokens), and the OpenAI SDK's generated # code can bypass Pydantic validation (e.g., via model_construct), # allowing None values. We normalize these to 0 to prevent TypeErrors. # Test entire objects being None (BeforeValidator) usage = Usage( requests=1, input_tokens=100, input_tokens_details=None, # type: ignore[arg-type] output_tokens=50, output_tokens_details=None, # type: ignore[arg-type] total_tokens=150, ) assert usage.input_tokens_details.cached_tokens == 0 assert usage.output_tokens_details.reasoning_tokens == 0 # Test fields within objects being None (__post_init__) input_details = InputTokensDetails.model_validate({"cache_write_tokens": 0, "cached_tokens": 0}) input_details.__dict__["cached_tokens"] = None input_details.__dict__["cache_write_tokens"] = None output_details = OutputTokensDetails(reasoning_tokens=0) output_details.__dict__["reasoning_tokens"] = None usage = Usage( requests=1, input_tokens=100, input_tokens_details=input_details, output_tokens=50, output_tokens_details=output_details, total_tokens=150, ) # __post_init__ should normalize None to 0 assert usage.input_tokens_details.cached_tokens == 0 assert getattr(usage.input_tokens_details, "cache_write_tokens", None) == 0 assert usage.output_tokens_details.reasoning_tokens == 0 def test_usage_normalizes_chat_completions_types(): # Chat Completions API uses PromptTokensDetails and CompletionTokensDetails, # while Usage expects InputTokensDetails and OutputTokensDetails (Responses API). # The BeforeValidator should convert between these types. prompt_details = PromptTokensDetails.model_validate( { "audio_tokens": 10, "cached_tokens": 50, "cache_write_tokens": 7, } ) completion_details = CompletionTokensDetails( accepted_prediction_tokens=5, audio_tokens=10, reasoning_tokens=100, rejected_prediction_tokens=2, ) usage = Usage( requests=1, input_tokens=200, input_tokens_details=prompt_details, # type: ignore[arg-type] output_tokens=150, output_tokens_details=completion_details, # type: ignore[arg-type] total_tokens=350, ) # Should convert to Responses API types, extracting the relevant fields assert isinstance(usage.input_tokens_details, InputTokensDetails) assert usage.input_tokens_details.cached_tokens == 50 assert getattr(usage.input_tokens_details, "cache_write_tokens", None) == 7 assert isinstance(usage.output_tokens_details, OutputTokensDetails) assert usage.output_tokens_details.reasoning_tokens == 100 def test_usage_serialization_preserves_cache_write_tokens() -> None: usage = Usage( requests=1, input_tokens=20, input_tokens_details=InputTokensDetails.model_validate( {"cache_write_tokens": 7, "cached_tokens": 3} ), output_tokens=5, total_tokens=25, request_usage_entries=[ RequestUsage( input_tokens=20, output_tokens=5, total_tokens=25, input_tokens_details=InputTokensDetails.model_validate( {"cache_write_tokens": 7, "cached_tokens": 3} ), output_tokens_details=OutputTokensDetails(reasoning_tokens=0), ) ], ) serialized = serialize_usage(usage) restored = deserialize_usage(serialized) assert serialized["input_tokens_details"] == [{"cached_tokens": 3, "cache_write_tokens": 7}] assert getattr(restored.input_tokens_details, "cache_write_tokens", None) == 7 assert ( getattr( restored.request_usage_entries[0].input_tokens_details, "cache_write_tokens", None, ) == 7 ) def test_usage_deserialization_defaults_legacy_cache_write_tokens() -> None: restored = deserialize_usage( { "requests": 1, "input_tokens": 20, "output_tokens": 5, "total_tokens": 25, "input_tokens_details": [{"cached_tokens": 3}], "request_usage_entries": [ { "input_tokens": 20, "output_tokens": 5, "total_tokens": 25, "input_tokens_details": {"cached_tokens": 3}, } ], } ) assert restored.input_tokens_details.cached_tokens == 3 assert getattr(restored.input_tokens_details, "cache_write_tokens", None) == 0 assert restored.request_usage_entries[0].input_tokens_details.cached_tokens == 3 assert ( getattr( restored.request_usage_entries[0].input_tokens_details, "cache_write_tokens", None, ) == 0 ) def test_usage_snapshot_delta_and_span_preserve_cache_write_tokens() -> None: start = Usage( requests=1, input_tokens=10, input_tokens_details=InputTokensDetails.model_validate( {"cache_write_tokens": 2, "cached_tokens": 3} ), output_tokens=4, total_tokens=14, ) end = Usage( requests=2, input_tokens=30, input_tokens_details=InputTokensDetails.model_validate( {"cache_write_tokens": 9, "cached_tokens": 8} ), output_tokens=10, total_tokens=40, ) snapshot = snapshot_usage(start) delta = usage_delta(snapshot, end) span_usage = model_usage_to_span_usage(delta) assert getattr(snapshot.input_tokens_details, "cache_write_tokens", None) == 2 assert delta.input_tokens_details.cached_tokens == 5 assert getattr(delta.input_tokens_details, "cache_write_tokens", None) == 7 assert span_usage["input_tokens_details"] == { "cached_tokens": 5, "cache_write_tokens": 7, }