Skip to content

Commit 5f9d8de

Browse files
authored
Handle errors in cost calculation in InstrumentedModel (#2834)
1 parent 9e67fe3 commit 5f9d8de

File tree

5 files changed

+90
-11
lines changed

5 files changed

+90
-11
lines changed

pydantic_ai_slim/pydantic_ai/models/instrumented.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -420,16 +420,20 @@ def _record_metrics():
420420
return
421421

422422
self.instrumentation_settings.handle_messages(messages, response, system, span)
423-
try:
424-
cost_attributes = {'operation.cost': float(response.cost().total_price)}
425-
except LookupError:
426-
cost_attributes = {}
427423

428424
attributes_to_set = {
429425
**response.usage.opentelemetry_attributes(),
430426
'gen_ai.response.model': response_model,
431-
**cost_attributes,
432427
}
428+
try:
429+
attributes_to_set['operation.cost'] = float(response.cost().total_price)
430+
except LookupError:
431+
# The cost of this provider/model is unknown, which is common.
432+
pass
433+
except Exception as e:
434+
warnings.warn(
435+
f'Failed to get cost from response: {type(e).__name__}: {e}', CostCalculationFailedWarning
436+
)
433437
if response.provider_response_id is not None:
434438
attributes_to_set['gen_ai.response.id'] = response.provider_response_id
435439
span.set_attributes(attributes_to_set)
@@ -480,3 +484,7 @@ def serialize_any(value: Any) -> str:
480484
return str(value)
481485
except Exception as e:
482486
return f'Unable to serialize: {e}'
487+
488+
489+
class CostCalculationFailedWarning(Warning):
490+
"""Warning raised when cost calculation fails."""

pydantic_ai_slim/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ dependencies = [
6060
"exceptiongroup; python_version < '3.11'",
6161
"opentelemetry-api>=1.28.0",
6262
"typing-inspection>=0.4.0",
63-
"genai-prices>=0.0.22",
63+
"genai-prices>=0.0.23",
6464
]
6565

6666
[tool.hatch.metadata.hooks.uv-dynamic-versioning.optional-dependencies]

tests/models/test_anthropic.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ async def test_async_request_prompt_caching(allow_model_requests: None):
252252
)
253253
last_message = result.all_messages()[-1]
254254
assert isinstance(last_message, ModelResponse)
255-
assert last_message.cost().total_price == snapshot(Decimal('0.00003488'))
255+
assert last_message.cost().total_price == snapshot(Decimal('0.00002688'))
256256

257257

258258
async def test_async_request_text_response(allow_model_requests: None):

tests/models/test_instrumented.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import pytest
99
from inline_snapshot import snapshot
10+
from inline_snapshot.extra import warns
1011
from logfire_api import DEFAULT_LOGFIRE_INSTANCE
1112
from opentelemetry._events import NoOpEventLoggerProvider
1213
from opentelemetry.trace import NoOpTracerProvider
@@ -1278,3 +1279,73 @@ def test_deprecated_event_mode_warning():
12781279
assert settings.event_mode == 'logs'
12791280
assert settings.version == 1
12801281
assert InstrumentationSettings().version == 2
1282+
1283+
1284+
async def test_response_cost_error(capfire: CaptureLogfire, monkeypatch: pytest.MonkeyPatch):
1285+
model = InstrumentedModel(MyModel())
1286+
1287+
messages: list[ModelMessage] = [ModelRequest(parts=[UserPromptPart('user_prompt')])]
1288+
monkeypatch.setattr(ModelResponse, 'cost', None)
1289+
1290+
with warns(
1291+
snapshot(
1292+
[
1293+
"CostCalculationFailedWarning: Failed to get cost from response: TypeError: 'NoneType' object is not callable"
1294+
]
1295+
)
1296+
):
1297+
await model.request(messages, model_settings=ModelSettings(), model_request_parameters=ModelRequestParameters())
1298+
1299+
assert capfire.exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot(
1300+
[
1301+
{
1302+
'name': 'chat gpt-4o',
1303+
'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False},
1304+
'parent': None,
1305+
'start_time': 1000000000,
1306+
'end_time': 2000000000,
1307+
'attributes': {
1308+
'gen_ai.operation.name': 'chat',
1309+
'gen_ai.system': 'openai',
1310+
'gen_ai.request.model': 'gpt-4o',
1311+
'server.address': 'example.com',
1312+
'server.port': 8000,
1313+
'model_request_parameters': {
1314+
'function_tools': [],
1315+
'builtin_tools': [],
1316+
'output_mode': 'text',
1317+
'output_object': None,
1318+
'output_tools': [],
1319+
'allow_text_output': True,
1320+
},
1321+
'logfire.span_type': 'span',
1322+
'logfire.msg': 'chat gpt-4o',
1323+
'gen_ai.input.messages': [{'role': 'user', 'parts': [{'type': 'text', 'content': 'user_prompt'}]}],
1324+
'gen_ai.output.messages': [
1325+
{
1326+
'role': 'assistant',
1327+
'parts': [
1328+
{'type': 'text', 'content': 'text1'},
1329+
{'type': 'tool_call', 'id': 'tool_call_1', 'name': 'tool1', 'arguments': 'args1'},
1330+
{'type': 'tool_call', 'id': 'tool_call_2', 'name': 'tool2', 'arguments': {'args2': 3}},
1331+
{'type': 'text', 'content': 'text2'},
1332+
],
1333+
'finish_reason': 'stop',
1334+
}
1335+
],
1336+
'logfire.json_schema': {
1337+
'type': 'object',
1338+
'properties': {
1339+
'gen_ai.input.messages': {'type': 'array'},
1340+
'gen_ai.output.messages': {'type': 'array'},
1341+
'model_request_parameters': {'type': 'object'},
1342+
},
1343+
},
1344+
'gen_ai.usage.input_tokens': 100,
1345+
'gen_ai.usage.output_tokens': 200,
1346+
'gen_ai.response.model': 'gpt-4o-2024-11-20',
1347+
'gen_ai.response.id': 'response_id',
1348+
},
1349+
}
1350+
]
1351+
)

uv.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)