Skip to content

Commit 6a25c79

Browse files
committed
add tests for cost extraction in LiteLLM and Usage objects
1 parent 1c1cbe5 commit 6a25c79

File tree

2 files changed

+286
-0
lines changed

2 files changed

+286
-0
lines changed
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""Tests for LiteLLM cost tracking functionality."""
2+
3+
import litellm
4+
import pytest
5+
from litellm.types.utils import Choices, Message, ModelResponse, Usage
6+
7+
from agents.extensions.models.litellm_model import LitellmModel
8+
from agents.model_settings import ModelSettings
9+
from agents.models.interface import ModelTracing
10+
11+
12+
@pytest.mark.allow_call_model_methods
13+
@pytest.mark.asyncio
14+
async def test_cost_extracted_when_track_cost_enabled(monkeypatch):
15+
"""Test that cost is extracted from LiteLLM response when track_cost=True."""
16+
17+
async def fake_acompletion(model, messages=None, **kwargs):
18+
msg = Message(role="assistant", content="Test response")
19+
choice = Choices(index=0, message=msg)
20+
response = ModelResponse(
21+
choices=[choice],
22+
usage=Usage(prompt_tokens=10, completion_tokens=20, total_tokens=30),
23+
)
24+
# Simulate LiteLLM's hidden params with cost.
25+
response._hidden_params = {"response_cost": 0.00042}
26+
return response
27+
28+
monkeypatch.setattr(litellm, "acompletion", fake_acompletion)
29+
30+
model = LitellmModel(model="test-model", api_key="test-key")
31+
result = await model.get_response(
32+
system_instructions=None,
33+
input=[],
34+
model_settings=ModelSettings(track_cost=True), # Enable cost tracking
35+
tools=[],
36+
output_schema=None,
37+
handoffs=[],
38+
tracing=ModelTracing.DISABLED,
39+
previous_response_id=None,
40+
)
41+
42+
# Verify that cost was extracted.
43+
assert result.usage.cost == 0.00042
44+
45+
46+
@pytest.mark.allow_call_model_methods
47+
@pytest.mark.asyncio
48+
async def test_cost_none_when_track_cost_disabled(monkeypatch):
49+
"""Test that cost is None when track_cost=False (default)."""
50+
51+
async def fake_acompletion(model, messages=None, **kwargs):
52+
msg = Message(role="assistant", content="Test response")
53+
choice = Choices(index=0, message=msg)
54+
response = ModelResponse(
55+
choices=[choice],
56+
usage=Usage(prompt_tokens=10, completion_tokens=20, total_tokens=30),
57+
)
58+
# Even if LiteLLM provides cost, it should be ignored.
59+
response._hidden_params = {"response_cost": 0.00042}
60+
return response
61+
62+
monkeypatch.setattr(litellm, "acompletion", fake_acompletion)
63+
64+
model = LitellmModel(model="test-model", api_key="test-key")
65+
result = await model.get_response(
66+
system_instructions=None,
67+
input=[],
68+
model_settings=ModelSettings(track_cost=False), # Disabled (default)
69+
tools=[],
70+
output_schema=None,
71+
handoffs=[],
72+
tracing=ModelTracing.DISABLED,
73+
previous_response_id=None,
74+
)
75+
76+
# Verify that cost is None when tracking is disabled.
77+
assert result.usage.cost is None
78+
79+
80+
@pytest.mark.allow_call_model_methods
81+
@pytest.mark.asyncio
82+
async def test_cost_none_when_not_provided(monkeypatch):
83+
"""Test that cost is None when LiteLLM doesn't provide it."""
84+
85+
async def fake_acompletion(model, messages=None, **kwargs):
86+
msg = Message(role="assistant", content="Test response")
87+
choice = Choices(index=0, message=msg)
88+
response = ModelResponse(
89+
choices=[choice],
90+
usage=Usage(prompt_tokens=10, completion_tokens=20, total_tokens=30),
91+
)
92+
# No _hidden_params or no cost in hidden params.
93+
return response
94+
95+
monkeypatch.setattr(litellm, "acompletion", fake_acompletion)
96+
97+
model = LitellmModel(model="test-model", api_key="test-key")
98+
result = await model.get_response(
99+
system_instructions=None,
100+
input=[],
101+
model_settings=ModelSettings(track_cost=True),
102+
tools=[],
103+
output_schema=None,
104+
handoffs=[],
105+
tracing=ModelTracing.DISABLED,
106+
previous_response_id=None,
107+
)
108+
109+
# Verify that cost is None when not provided.
110+
assert result.usage.cost is None
111+
112+
113+
@pytest.mark.allow_call_model_methods
114+
@pytest.mark.asyncio
115+
async def test_cost_with_empty_hidden_params(monkeypatch):
116+
"""Test that cost extraction handles empty _hidden_params gracefully."""
117+
118+
async def fake_acompletion(model, messages=None, **kwargs):
119+
msg = Message(role="assistant", content="Test response")
120+
choice = Choices(index=0, message=msg)
121+
response = ModelResponse(
122+
choices=[choice],
123+
usage=Usage(prompt_tokens=10, completion_tokens=20, total_tokens=30),
124+
)
125+
# Empty hidden params.
126+
response._hidden_params = {}
127+
return response
128+
129+
monkeypatch.setattr(litellm, "acompletion", fake_acompletion)
130+
131+
model = LitellmModel(model="test-model", api_key="test-key")
132+
result = await model.get_response(
133+
system_instructions=None,
134+
input=[],
135+
model_settings=ModelSettings(track_cost=True),
136+
tools=[],
137+
output_schema=None,
138+
handoffs=[],
139+
tracing=ModelTracing.DISABLED,
140+
previous_response_id=None,
141+
)
142+
143+
# Verify that cost is None with empty hidden params.
144+
assert result.usage.cost is None
145+
146+
147+
@pytest.mark.allow_call_model_methods
148+
@pytest.mark.asyncio
149+
async def test_cost_extraction_preserves_other_usage_fields(monkeypatch):
150+
"""Test that cost extraction doesn't affect other usage fields."""
151+
152+
async def fake_acompletion(model, messages=None, **kwargs):
153+
msg = Message(role="assistant", content="Test response")
154+
choice = Choices(index=0, message=msg)
155+
response = ModelResponse(
156+
choices=[choice],
157+
usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150),
158+
)
159+
response._hidden_params = {"response_cost": 0.001}
160+
return response
161+
162+
monkeypatch.setattr(litellm, "acompletion", fake_acompletion)
163+
164+
model = LitellmModel(model="test-model", api_key="test-key")
165+
result = await model.get_response(
166+
system_instructions=None,
167+
input=[],
168+
model_settings=ModelSettings(track_cost=True),
169+
tools=[],
170+
output_schema=None,
171+
handoffs=[],
172+
tracing=ModelTracing.DISABLED,
173+
previous_response_id=None,
174+
)
175+
176+
# Verify all usage fields are correct.
177+
assert result.usage.input_tokens == 100
178+
assert result.usage.output_tokens == 50
179+
assert result.usage.total_tokens == 150
180+
assert result.usage.cost == 0.001
181+
assert result.usage.requests == 1

tests/test_cost_in_run.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Test cost extraction in run.py for streaming responses."""
2+
3+
from openai.types.responses import Response, ResponseOutputMessage, ResponseUsage
4+
from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails
5+
6+
from agents.usage import Usage
7+
8+
9+
def test_usage_extracts_cost_from_litellm_attribute():
10+
"""Test that Usage extracts cost from Response._litellm_cost attribute."""
11+
# Simulate a Response object with _litellm_cost attached (as done by LitellmModel)
12+
response = Response(
13+
id="test-id",
14+
created_at=123456,
15+
model="test-model",
16+
object="response",
17+
output=[
18+
ResponseOutputMessage(
19+
id="msg-1",
20+
role="assistant",
21+
type="message",
22+
content=[],
23+
status="completed",
24+
)
25+
],
26+
usage=ResponseUsage(
27+
input_tokens=100,
28+
output_tokens=50,
29+
total_tokens=150,
30+
input_tokens_details=InputTokensDetails(cached_tokens=10),
31+
output_tokens_details=OutputTokensDetails(reasoning_tokens=5),
32+
),
33+
tool_choice="auto",
34+
parallel_tool_calls=False,
35+
tools=[],
36+
)
37+
38+
# Attach cost as LitellmModel does
39+
response._litellm_cost = 0.00123 # type: ignore
40+
41+
# Simulate what run.py does in ResponseCompletedEvent handling
42+
cost = getattr(response, "_litellm_cost", None)
43+
44+
assert response.usage is not None
45+
usage = Usage(
46+
requests=1,
47+
input_tokens=response.usage.input_tokens,
48+
output_tokens=response.usage.output_tokens,
49+
total_tokens=response.usage.total_tokens,
50+
input_tokens_details=response.usage.input_tokens_details,
51+
output_tokens_details=response.usage.output_tokens_details,
52+
cost=cost,
53+
)
54+
55+
# Verify cost was extracted
56+
assert usage.cost == 0.00123
57+
assert usage.input_tokens == 100
58+
assert usage.output_tokens == 50
59+
60+
61+
def test_usage_cost_none_when_attribute_missing():
62+
"""Test that Usage.cost is None when _litellm_cost attribute is missing."""
63+
# Response without _litellm_cost attribute (normal OpenAI response)
64+
response = Response(
65+
id="test-id",
66+
created_at=123456,
67+
model="test-model",
68+
object="response",
69+
output=[
70+
ResponseOutputMessage(
71+
id="msg-1",
72+
role="assistant",
73+
type="message",
74+
content=[],
75+
status="completed",
76+
)
77+
],
78+
usage=ResponseUsage(
79+
input_tokens=100,
80+
output_tokens=50,
81+
total_tokens=150,
82+
input_tokens_details=InputTokensDetails(cached_tokens=0),
83+
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
84+
),
85+
tool_choice="auto",
86+
parallel_tool_calls=False,
87+
tools=[],
88+
)
89+
90+
# Simulate what run.py does
91+
cost = getattr(response, "_litellm_cost", None)
92+
93+
assert response.usage is not None
94+
usage = Usage(
95+
requests=1,
96+
input_tokens=response.usage.input_tokens,
97+
output_tokens=response.usage.output_tokens,
98+
total_tokens=response.usage.total_tokens,
99+
input_tokens_details=response.usage.input_tokens_details,
100+
output_tokens_details=response.usage.output_tokens_details,
101+
cost=cost,
102+
)
103+
104+
# Verify cost is None
105+
assert usage.cost is None

0 commit comments

Comments
 (0)