Files
wehub-resource-sync 9201ef759e
Harness Compat / harness compat (push) Failing after 0s
CI / test on 3.12 (standard) (push) Has been cancelled
CI / test on 3.13 (standard) (push) Has been cancelled
CI / test on 3.14 (standard) (push) Has been cancelled
CI / test on 3.10 (all-extras) (push) Has been cancelled
CI / test on 3.11 (all-extras) (push) Has been cancelled
CI / test on 3.12 (all-extras) (push) Has been cancelled
CI / test on 3.14 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.10 (pydantic-evals) (push) Has been cancelled
CI / test on 3.11 (pydantic-evals) (push) Has been cancelled
CI / test on 3.12 (pydantic-evals) (push) Has been cancelled
CI / deploy-docs-preview (push) Has been cancelled
CI / build release artifacts (push) Has been cancelled
CI / publish to PyPI (push) Has been cancelled
CI / Send tweet (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / mypy (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / test on 3.10 (standard) (push) Has been cancelled
CI / test on 3.11 (standard) (push) Has been cancelled
CI / test on 3.13 (all-extras) (push) Has been cancelled
CI / test on 3.14 (all-extras) (push) Has been cancelled
CI / test on 3.10 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.11 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.12 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.13 (pydantic-ai-slim) (push) Has been cancelled
CI / test on 3.13 (pydantic-evals) (push) Has been cancelled
CI / test on 3.14 (pydantic-evals) (push) Has been cancelled
CI / test on 3.10 (lowest-versions) (push) Has been cancelled
CI / test on 3.11 (lowest-versions) (push) Has been cancelled
CI / test on 3.12 (lowest-versions) (push) Has been cancelled
CI / test on 3.13 (lowest-versions) (push) Has been cancelled
CI / test on 3.14 (lowest-versions) (push) Has been cancelled
CI / test examples on 3.11 (push) Has been cancelled
CI / test examples on 3.12 (push) Has been cancelled
CI / test examples on 3.13 (push) Has been cancelled
CI / test examples on 3.14 (push) Has been cancelled
CI / coverage (push) Has been cancelled
CI / check (push) Has been cancelled
CI / deploy-docs (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:27:52 +08:00

2147 lines
82 KiB
Python

from __future__ import annotations
from collections.abc import AsyncGenerator, AsyncIterator
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Literal
import pytest
from opentelemetry.trace import NoOpTracerProvider
from pydantic_ai import (
AudioUrl,
BinaryContent,
CachePoint,
DocumentUrl,
FilePart,
FinalResultEvent,
ImageUrl,
ModelMessage,
ModelRequest,
ModelResponse,
ModelResponseStreamEvent,
NativeToolCallPart,
NativeToolReturnPart,
PartDeltaEvent,
PartEndEvent,
PartStartEvent,
RetryPromptPart,
SystemPromptPart,
TextPart,
TextPartDelta,
ThinkingPart,
ToolCallPart,
ToolReturnPart,
UserPromptPart,
VideoUrl,
)
from pydantic_ai._run_context import RunContext
from pydantic_ai._warnings import PydanticAIDeprecationWarning
from pydantic_ai.models import Model, ModelRequestParameters, StreamedResponse
from pydantic_ai.models.instrumented import InstrumentationSettings, InstrumentedModel
from pydantic_ai.settings import ModelSettings
from pydantic_ai.tools import ToolDefinition
from pydantic_ai.usage import RequestUsage
from .._inline_snapshot import snapshot, warns
from ..conftest import IsDatetime, IsFloat, IsInt, IsStr, try_import
with try_import() as imports_successful:
from logfire.testing import CaptureLogfire
pytestmark = [
pytest.mark.skipif(not imports_successful(), reason='logfire not installed'),
pytest.mark.anyio,
]
def deprecated_instrumentation_settings(
version: Literal[2, 3, 4], *, include_binary_content: bool = True, include_content: bool = True
) -> InstrumentationSettings:
with pytest.warns(
PydanticAIDeprecationWarning,
match=r'Instrumentation format versions 2, 3, and 4 are deprecated',
):
return InstrumentationSettings(
version=version, include_binary_content=include_binary_content, include_content=include_content
)
class MyModel(Model):
# Use a system and model name that have a known price
@property
def system(self) -> str:
return 'openai'
@property
def model_name(self) -> str:
return 'gpt-4o'
@property
def base_url(self) -> str:
return 'https://example.com:8000/foo'
async def request(
self,
messages: list[ModelMessage],
model_settings: ModelSettings | None,
model_request_parameters: ModelRequestParameters,
) -> ModelResponse:
return ModelResponse(
parts=[
TextPart('text1'),
ToolCallPart('tool1', 'args1', 'tool_call_1'),
ToolCallPart('tool2', {'args2': 3}, 'tool_call_2'),
TextPart('text2'),
{}, # test unexpected parts # type: ignore
],
usage=RequestUsage(
input_tokens=100,
output_tokens=200,
cache_write_tokens=10,
cache_read_tokens=20,
input_audio_tokens=10,
cache_audio_read_tokens=5,
output_audio_tokens=30,
details={'reasoning_tokens': 30},
),
model_name='gpt-4o-2024-11-20',
provider_details=dict(finish_reason='stop', foo='bar'),
provider_response_id='response_id',
)
@asynccontextmanager
async def request_stream(
self,
messages: list[ModelMessage],
model_settings: ModelSettings | None,
model_request_parameters: ModelRequestParameters,
run_context: RunContext | None = None,
) -> AsyncGenerator[StreamedResponse]:
yield MyResponseStream(model_request_parameters=model_request_parameters)
async def count_tokens(
self,
messages: list[ModelMessage],
model_settings: ModelSettings | None,
model_request_parameters: ModelRequestParameters,
) -> RequestUsage:
return RequestUsage(input_tokens=10)
class MyResponseStream(StreamedResponse):
async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
self._usage = RequestUsage(input_tokens=300, output_tokens=400)
for event in self._parts_manager.handle_text_delta(vendor_part_id=0, content='text1'):
yield event
for event in self._parts_manager.handle_text_delta(vendor_part_id=0, content='text2'):
yield event
@property
def model_name(self) -> str:
return 'gpt-4o-2024-11-20'
@property
def provider_name(self) -> str:
return 'openai'
@property
def provider_url(self) -> str:
return 'https://api.openai.com'
@property
def timestamp(self) -> datetime:
return datetime(2022, 1, 1)
async def test_instrumented_model(capfire: CaptureLogfire):
model = InstrumentedModel(MyModel(), InstrumentationSettings())
assert model.system == 'openai'
assert model.model_name == 'gpt-4o'
assert model.model_id == 'openai:gpt-4o'
messages = [
ModelRequest(
instructions='instructions',
parts=[
SystemPromptPart('system_prompt'),
UserPromptPart('user_prompt'),
ToolReturnPart('tool3', 'tool_return_content', 'tool_call_3'),
RetryPromptPart('retry_prompt1', tool_name='tool4', tool_call_id='tool_call_4'),
RetryPromptPart('retry_prompt2'),
{}, # test unexpected parts # type: ignore
],
timestamp=IsDatetime(),
),
ModelResponse(parts=[TextPart('text3')]),
]
await model.request(
messages,
model_settings=ModelSettings(temperature=1),
model_request_parameters=ModelRequestParameters(
function_tools=[],
allow_text_output=True,
output_tools=[],
output_mode='text',
output_object=None,
),
)
assert capfire.exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot(
[
{
'name': 'chat gpt-4o',
'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False},
'parent': None,
'start_time': 1000000000,
'end_time': 2000000000,
'attributes': {
'gen_ai.operation.name': 'chat',
'gen_ai.provider.name': 'openai',
'gen_ai.system': 'openai',
'gen_ai.request.model': 'gpt-4o',
'server.address': 'example.com',
'server.port': 8000,
'model_request_parameters': {
'function_tools': [],
'native_tools': [],
'output_mode': 'text',
'output_object': None,
'output_tools': [],
'prompted_output_template': None,
'allow_text_output': True,
'allow_image_output': False,
'instruction_parts': None,
'thinking': None,
},
'logfire.json_schema': {
'type': 'object',
'properties': {
'gen_ai.input.messages': {'type': 'array'},
'gen_ai.output.messages': {'type': 'array'},
'gen_ai.system_instructions': {'type': 'array'},
'model_request_parameters': {'type': 'object'},
},
},
'gen_ai.request.temperature': 1,
'logfire.msg': 'chat gpt-4o',
'gen_ai.input.messages': [
{'role': 'system', 'parts': [{'type': 'text', 'content': 'system_prompt'}]},
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'user_prompt'},
{
'type': 'tool_call_response',
'id': 'tool_call_3',
'name': 'tool3',
'result': 'tool_return_content',
},
{
'type': 'tool_call_response',
'id': 'tool_call_4',
'name': 'tool4',
'result': """\
retry_prompt1
Fix the errors and try again.\
""",
},
{
'type': 'text',
'content': """\
Validation feedback:
retry_prompt2
Fix the errors and try again.\
""",
},
],
},
{'role': 'assistant', 'parts': [{'type': 'text', 'content': 'text3'}]},
],
'gen_ai.output.messages': [
{
'role': 'assistant',
'parts': [
{'type': 'text', 'content': 'text1'},
{'type': 'tool_call', 'id': 'tool_call_1', 'name': 'tool1', 'arguments': 'args1'},
{'type': 'tool_call', 'id': 'tool_call_2', 'name': 'tool2', 'arguments': {'args2': 3}},
{'type': 'text', 'content': 'text2'},
],
}
],
'logfire.span_type': 'span',
'gen_ai.response.model': 'gpt-4o-2024-11-20',
'gen_ai.response.id': 'response_id',
'gen_ai.system_instructions': [{'type': 'text', 'content': 'instructions'}],
'gen_ai.usage.cache_creation.input_tokens': 10,
'gen_ai.usage.cache_read.input_tokens': 20,
'gen_ai.usage.details.reasoning_tokens': 30,
'gen_ai.usage.details.cache_write_tokens': 10,
'gen_ai.usage.details.cache_read_tokens': 20,
'gen_ai.usage.details.input_audio_tokens': 10,
'gen_ai.usage.details.cache_audio_read_tokens': 5,
'gen_ai.usage.details.output_audio_tokens': 30,
'gen_ai.usage.input_tokens': 100,
'gen_ai.usage.output_tokens': 200,
'operation.cost': 0.002225,
},
},
]
)
assert capfire.log_exporter.exported_logs_as_dicts() == snapshot([])
async def test_instrumented_model_not_recording():
model = InstrumentedModel(
MyModel(),
InstrumentationSettings(tracer_provider=NoOpTracerProvider()),
)
messages: list[ModelMessage] = [ModelRequest(parts=[SystemPromptPart('system_prompt')], timestamp=IsDatetime())]
await model.request(
messages,
model_settings=ModelSettings(temperature=1),
model_request_parameters=ModelRequestParameters(
function_tools=[],
allow_text_output=True,
output_tools=[],
output_mode='text',
output_object=None,
),
)
async def test_instrumented_model_serializes_lone_surrogates_without_crashing(capfire: CaptureLogfire):
"""Lone surrogates in message content make `to_json` raise; instrumentation must not crash the run.
Text decoded with `errors='surrogateescape'` can carry unpaired surrogates. `to_json` rejects
them, so `handle_messages` falls back to a serializer that escapes them instead of propagating.
"""
model = InstrumentedModel(MyModel(), InstrumentationSettings())
surrogate = 'before\udce4after'
messages: list[ModelMessage] = [ModelRequest(parts=[UserPromptPart(surrogate)], timestamp=IsDatetime())]
await model.request(messages, model_settings=None, model_request_parameters=ModelRequestParameters())
attributes = capfire.exporter.exported_spans_as_dict()[0]['attributes']
assert attributes['gen_ai.input.messages'] == snapshot(
'[{"role":"user","parts":[{"type":"text","content":"before\\udce4after"}]}]'
)
def test_safe_to_json_falls_back_on_lone_surrogates():
"""`safe_to_json` returns `to_json` output normally and escapes lone surrogates on the fallback."""
from pydantic_ai._instrumentation import safe_to_json
assert safe_to_json({'a': [1, 'b']}) == snapshot(b'{"a":[1,"b"]}')
assert safe_to_json('x\udce4y') == snapshot(b'"x\\udce4y"')
def test_instrumentation_settings_rejects_removed_version():
with pytest.raises(ValueError, match='Instrumentation version must be one of 2, 3, 4, or 5'):
InstrumentationSettings(version=1) # pyright: ignore[reportArgumentType]
@pytest.mark.parametrize('version', [2, 3, 4])
def test_instrumentation_settings_warns_for_deprecated_versions(version: Literal[2, 3, 4]):
settings = deprecated_instrumentation_settings(version=version)
assert settings.version == version
def test_instrumentation_settings_current_version_does_not_warn(recwarn: pytest.WarningsRecorder):
InstrumentationSettings()
InstrumentationSettings(version=5)
deprecation_warnings = [w for w in recwarn if issubclass(w.category, PydanticAIDeprecationWarning)]
assert deprecation_warnings == []
async def test_instrumented_model_stream(capfire: CaptureLogfire):
model = InstrumentedModel(MyModel(), InstrumentationSettings())
messages: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart('user_prompt'),
],
timestamp=IsDatetime(),
),
]
async with model.request_stream(
messages,
model_settings=ModelSettings(temperature=1),
model_request_parameters=ModelRequestParameters(
function_tools=[],
allow_text_output=True,
output_tools=[],
output_mode='text',
output_object=None,
),
) as response_stream:
assert [event async for event in response_stream] == snapshot(
[
PartStartEvent(index=0, part=TextPart(content='text1')),
FinalResultEvent(tool_name=None, tool_call_id=None),
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta='text2')),
PartEndEvent(index=0, part=TextPart(content='text1text2')),
]
)
assert capfire.exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot(
[
{
'name': 'chat gpt-4o',
'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False},
'parent': None,
'start_time': 1000000000,
'end_time': 2000000000,
'attributes': {
'gen_ai.operation.name': 'chat',
'gen_ai.provider.name': 'openai',
'gen_ai.system': 'openai',
'gen_ai.request.model': 'gpt-4o',
'server.address': 'example.com',
'server.port': 8000,
'model_request_parameters': {
'function_tools': [],
'native_tools': [],
'output_mode': 'text',
'output_object': None,
'output_tools': [],
'prompted_output_template': None,
'allow_text_output': True,
'allow_image_output': False,
'instruction_parts': None,
'thinking': None,
},
'logfire.json_schema': {
'type': 'object',
'properties': {
'gen_ai.input.messages': {'type': 'array'},
'gen_ai.output.messages': {'type': 'array'},
'model_request_parameters': {'type': 'object'},
},
},
'gen_ai.request.temperature': 1,
'logfire.msg': 'chat gpt-4o',
'gen_ai.input.messages': [{'role': 'user', 'parts': [{'type': 'text', 'content': 'user_prompt'}]}],
'gen_ai.output.messages': [
{'role': 'assistant', 'parts': [{'type': 'text', 'content': 'text1text2'}]}
],
'logfire.span_type': 'span',
'gen_ai.response.model': 'gpt-4o-2024-11-20',
'gen_ai.usage.input_tokens': 300,
'gen_ai.usage.output_tokens': 400,
'operation.cost': 0.00475,
'gen_ai.client.operation.time_to_first_chunk': IsFloat(),
},
},
]
)
assert capfire.log_exporter.exported_logs_as_dicts() == snapshot([])
# Streaming records the time-to-first-chunk histogram (value is non-deterministic, so
# assert shape rather than snapshot the float).
ttft_metrics = [
m for m in capfire.get_collected_metrics() if m['name'] == 'gen_ai.client.operation.time_to_first_chunk'
]
assert len(ttft_metrics) == 1
assert ttft_metrics[0]['unit'] == 's'
assert len(ttft_metrics[0]['data']['data_points']) == 1
async def test_instrumented_model_stream_break(capfire: CaptureLogfire):
model = InstrumentedModel(MyModel(), InstrumentationSettings())
messages: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart('user_prompt'),
],
timestamp=IsDatetime(),
),
]
with pytest.raises(RuntimeError):
async with model.request_stream(
messages,
model_settings=ModelSettings(temperature=1),
model_request_parameters=ModelRequestParameters(
function_tools=[],
allow_text_output=True,
output_tools=[],
output_mode='text',
output_object=None,
),
) as response_stream:
async for event in response_stream: # pragma: no branch
assert event == PartStartEvent(index=0, part=TextPart(content='text1'))
raise RuntimeError
assert capfire.exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot(
[
{
'name': 'chat gpt-4o',
'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False},
'parent': None,
'start_time': 1000000000,
'end_time': 3000000000,
'attributes': {
'gen_ai.operation.name': 'chat',
'gen_ai.provider.name': 'openai',
'gen_ai.system': 'openai',
'gen_ai.request.model': 'gpt-4o',
'server.address': 'example.com',
'server.port': 8000,
'model_request_parameters': {
'function_tools': [],
'native_tools': [],
'output_mode': 'text',
'output_object': None,
'output_tools': [],
'prompted_output_template': None,
'allow_text_output': True,
'allow_image_output': False,
'instruction_parts': None,
'thinking': None,
},
'logfire.json_schema': {
'type': 'object',
'properties': {
'gen_ai.input.messages': {'type': 'array'},
'gen_ai.output.messages': {'type': 'array'},
'model_request_parameters': {'type': 'object'},
},
},
'gen_ai.request.temperature': 1,
'logfire.msg': 'chat gpt-4o',
'gen_ai.input.messages': [{'role': 'user', 'parts': [{'type': 'text', 'content': 'user_prompt'}]}],
'gen_ai.output.messages': [{'role': 'assistant', 'parts': [{'type': 'text', 'content': 'text1'}]}],
'logfire.span_type': 'span',
'gen_ai.response.model': 'gpt-4o-2024-11-20',
'gen_ai.usage.input_tokens': 300,
'gen_ai.usage.output_tokens': 400,
'operation.cost': 0.00475,
'gen_ai.client.operation.time_to_first_chunk': IsFloat(),
'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000',
'logfire.level_num': 17,
},
'events': [
{
'name': 'exception',
'timestamp': 2000000000,
'attributes': {
'exception.type': 'RuntimeError',
'exception.message': '',
'exception.stacktrace': 'RuntimeError',
'exception.escaped': 'False',
},
}
],
},
]
)
assert capfire.log_exporter.exported_logs_as_dicts() == snapshot([])
async def test_instrumented_model_attributes_mode(capfire: CaptureLogfire):
model = InstrumentedModel(MyModel(), InstrumentationSettings())
assert model.system == 'openai'
assert model.model_name == 'gpt-4o'
messages = [
ModelRequest(
instructions='instructions',
parts=[
SystemPromptPart('system_prompt'),
UserPromptPart('user_prompt'),
ToolReturnPart('tool3', 'tool_return_content', 'tool_call_3'),
RetryPromptPart('retry_prompt1', tool_name='tool4', tool_call_id='tool_call_4'),
RetryPromptPart('retry_prompt2'),
{}, # test unexpected parts # type: ignore
],
timestamp=IsDatetime(),
),
ModelResponse(parts=[TextPart('text3')]),
]
await model.request(
messages,
model_settings=ModelSettings(temperature=1),
model_request_parameters=ModelRequestParameters(
function_tools=[],
allow_text_output=True,
output_tools=[],
output_mode='text',
output_object=None,
),
)
assert capfire.exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot(
[
{
'name': 'chat gpt-4o',
'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False},
'parent': None,
'start_time': 1000000000,
'end_time': 2000000000,
'attributes': {
'gen_ai.operation.name': 'chat',
'gen_ai.provider.name': 'openai',
'gen_ai.system': 'openai',
'gen_ai.request.model': 'gpt-4o',
'server.address': 'example.com',
'server.port': 8000,
'model_request_parameters': {
'function_tools': [],
'native_tools': [],
'output_mode': 'text',
'output_object': None,
'output_tools': [],
'prompted_output_template': None,
'allow_text_output': True,
'allow_image_output': False,
'instruction_parts': None,
'thinking': None,
},
'gen_ai.request.temperature': 1,
'logfire.msg': 'chat gpt-4o',
'logfire.span_type': 'span',
'gen_ai.input.messages': [
{
'role': 'system',
'parts': [
{'type': 'text', 'content': 'system_prompt'},
],
},
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'user_prompt'},
{
'type': 'tool_call_response',
'id': 'tool_call_3',
'name': 'tool3',
'result': 'tool_return_content',
},
{
'type': 'tool_call_response',
'id': 'tool_call_4',
'name': 'tool4',
'result': """\
retry_prompt1
Fix the errors and try again.\
""",
},
{
'type': 'text',
'content': """\
Validation feedback:
retry_prompt2
Fix the errors and try again.\
""",
},
],
},
{'role': 'assistant', 'parts': [{'type': 'text', 'content': 'text3'}]},
],
'gen_ai.output.messages': [
{
'role': 'assistant',
'parts': [
{'type': 'text', 'content': 'text1'},
{'type': 'tool_call', 'id': 'tool_call_1', 'name': 'tool1', 'arguments': 'args1'},
{
'type': 'tool_call',
'id': 'tool_call_2',
'name': 'tool2',
'arguments': {'args2': 3},
},
{'type': 'text', 'content': 'text2'},
],
}
],
'gen_ai.response.model': 'gpt-4o-2024-11-20',
'gen_ai.system_instructions': [{'type': 'text', 'content': 'instructions'}],
'gen_ai.usage.input_tokens': 100,
'gen_ai.usage.output_tokens': 200,
'gen_ai.usage.cache_creation.input_tokens': 10,
'gen_ai.usage.cache_read.input_tokens': 20,
'gen_ai.usage.details.reasoning_tokens': 30,
'gen_ai.usage.details.cache_write_tokens': 10,
'gen_ai.usage.details.cache_read_tokens': 20,
'gen_ai.usage.details.input_audio_tokens': 10,
'gen_ai.usage.details.cache_audio_read_tokens': 5,
'gen_ai.usage.details.output_audio_tokens': 30,
'logfire.json_schema': {
'type': 'object',
'properties': {
'gen_ai.input.messages': {'type': 'array'},
'gen_ai.output.messages': {'type': 'array'},
'gen_ai.system_instructions': {'type': 'array'},
'model_request_parameters': {'type': 'object'},
},
},
'operation.cost': 0.002225,
'gen_ai.response.id': 'response_id',
},
},
]
)
assert capfire.get_collected_metrics() == snapshot(
[
{
'name': 'gen_ai.client.token.usage',
'description': 'Measures number of input and output tokens used',
'unit': '{token}',
'data': {
'data_points': [
{
'attributes': {
'gen_ai.provider.name': 'openai',
'gen_ai.system': 'openai',
'gen_ai.operation.name': 'chat',
'gen_ai.request.model': 'gpt-4o',
'gen_ai.response.model': 'gpt-4o-2024-11-20',
'gen_ai.token.type': 'input',
},
'start_time_unix_nano': IsInt(),
'time_unix_nano': IsInt(),
'count': 1,
'sum': 100,
'scale': 20,
'zero_count': 0,
'positive': {'offset': 6966588, 'bucket_counts': [1]},
'negative': {'offset': 0, 'bucket_counts': [0]},
'flags': 0,
'min': 100,
'max': 100,
'exemplars': [],
},
{
'attributes': {
'gen_ai.provider.name': 'openai',
'gen_ai.system': 'openai',
'gen_ai.operation.name': 'chat',
'gen_ai.request.model': 'gpt-4o',
'gen_ai.response.model': 'gpt-4o-2024-11-20',
'gen_ai.token.type': 'output',
},
'start_time_unix_nano': IsInt(),
'time_unix_nano': IsInt(),
'count': 1,
'sum': 200,
'scale': 20,
'zero_count': 0,
'positive': {'offset': 8015164, 'bucket_counts': [1]},
'negative': {'offset': 0, 'bucket_counts': [0]},
'flags': 0,
'min': 200,
'max': 200,
'exemplars': [],
},
],
'aggregation_temporality': 1,
},
},
{
'name': 'operation.cost',
'description': 'Monetary cost',
'unit': '{USD}',
'data': {
'data_points': [
{
'attributes': {
'gen_ai.provider.name': 'openai',
'gen_ai.system': 'openai',
'gen_ai.operation.name': 'chat',
'gen_ai.request.model': 'gpt-4o',
'gen_ai.response.model': 'gpt-4o-2024-11-20',
},
'start_time_unix_nano': IsInt(),
'time_unix_nano': IsInt(),
'count': 1,
'sum': 0.002225,
'scale': 20,
'zero_count': 0,
'positive': {'offset': -9240030, 'bucket_counts': [1]},
'negative': {'offset': 0, 'bucket_counts': [0]},
'flags': 0,
'min': 0.002225,
'max': 0.002225,
'exemplars': [],
}
],
'aggregation_temporality': 1,
},
},
]
)
def test_messages_to_otel_message_parts_compaction_part():
"""CompactionPart is skipped in otel_message_parts (not a standard GenAI convention type)."""
from pydantic_ai.messages import CompactionPart
messages: list[ModelMessage] = [
ModelResponse(parts=[CompactionPart(content='Summary.', provider_name='anthropic'), TextPart('response')]),
]
settings = InstrumentationSettings()
otel_messages = settings.messages_to_otel_messages(messages)
# CompactionPart is skipped; only TextPart appears
assert otel_messages == snapshot([{'role': 'assistant', 'parts': [{'type': 'text', 'content': 'response'}]}])
def test_messages_to_otel_messages_multimodal_v3(document_content: BinaryContent):
"""Test that version 3 keeps the pre-v4 multimodal format."""
messages: list[ModelMessage] = [
ModelRequest(
parts=[UserPromptPart(content=['user_prompt', ImageUrl('https://example.com/image.png')])],
timestamp=IsDatetime(),
),
ModelRequest(
parts=[UserPromptPart(content=['user_prompt2', AudioUrl('https://example.com/audio.mp3')])],
timestamp=IsDatetime(),
),
ModelRequest(
parts=[UserPromptPart(content=['user_prompt3', DocumentUrl('https://example.com/document.pdf')])],
timestamp=IsDatetime(),
),
ModelRequest(
parts=[UserPromptPart(content=['user_prompt4', VideoUrl('https://example.com/video.mp4')])],
timestamp=IsDatetime(),
),
ModelRequest(
parts=[
UserPromptPart(
content=[
'user_prompt5',
ImageUrl('https://example.com/image2.png'),
AudioUrl('https://example.com/audio2.mp3'),
DocumentUrl('https://example.com/document2.pdf'),
VideoUrl('https://example.com/video2.mp4'),
]
)
],
timestamp=IsDatetime(),
),
ModelRequest(parts=[UserPromptPart(content=['user_prompt6', document_content])], timestamp=IsDatetime()),
ModelResponse(parts=[TextPart('text1')]),
ModelResponse(parts=[FilePart(content=document_content)]),
]
settings = deprecated_instrumentation_settings(version=3)
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'user_prompt'},
{'type': 'image-url', 'url': 'https://example.com/image.png'},
],
},
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'user_prompt2'},
{'type': 'audio-url', 'url': 'https://example.com/audio.mp3'},
],
},
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'user_prompt3'},
{'type': 'document-url', 'url': 'https://example.com/document.pdf'},
],
},
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'user_prompt4'},
{'type': 'video-url', 'url': 'https://example.com/video.mp4'},
],
},
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'user_prompt5'},
{'type': 'image-url', 'url': 'https://example.com/image2.png'},
{'type': 'audio-url', 'url': 'https://example.com/audio2.mp3'},
{'type': 'document-url', 'url': 'https://example.com/document2.pdf'},
{'type': 'video-url', 'url': 'https://example.com/video2.mp4'},
],
},
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'user_prompt6'},
{
'type': 'binary',
'media_type': 'application/pdf',
'content': IsStr(),
},
],
},
{'role': 'assistant', 'parts': [{'type': 'text', 'content': 'text1'}]},
{
'role': 'assistant',
'parts': [
{
'type': 'binary',
'media_type': 'application/pdf',
'content': IsStr(),
}
],
},
]
)
settings_without_binary = deprecated_instrumentation_settings(version=3, include_binary_content=False)
assert settings_without_binary.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'user_prompt'},
{'type': 'image-url', 'url': 'https://example.com/image.png'},
],
},
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'user_prompt2'},
{'type': 'audio-url', 'url': 'https://example.com/audio.mp3'},
],
},
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'user_prompt3'},
{'type': 'document-url', 'url': 'https://example.com/document.pdf'},
],
},
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'user_prompt4'},
{'type': 'video-url', 'url': 'https://example.com/video.mp4'},
],
},
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'user_prompt5'},
{'type': 'image-url', 'url': 'https://example.com/image2.png'},
{'type': 'audio-url', 'url': 'https://example.com/audio2.mp3'},
{'type': 'document-url', 'url': 'https://example.com/document2.pdf'},
{'type': 'video-url', 'url': 'https://example.com/video2.mp4'},
],
},
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'user_prompt6'},
{'type': 'binary', 'media_type': 'application/pdf'},
],
},
{'role': 'assistant', 'parts': [{'type': 'text', 'content': 'text1'}]},
{
'role': 'assistant',
'parts': [
{'type': 'binary', 'media_type': 'application/pdf'},
],
},
]
)
def test_messages_to_otel_messages_multimodal_v4():
"""Test that version 4 uses GenAI semantic conventions for multimodal inputs."""
messages: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(
content=[
'Describe these files',
ImageUrl('https://example.com/image.jpg', media_type='image/jpeg'),
AudioUrl('https://example.com/audio.mp3', media_type='audio/mpeg'),
DocumentUrl('https://example.com/doc.pdf', media_type='application/pdf'),
VideoUrl('https://example.com/video.mp4', media_type='video/mp4'),
]
)
],
timestamp=IsDatetime(),
),
]
settings = deprecated_instrumentation_settings(version=4)
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'Describe these files'},
{
'type': 'uri',
'modality': 'image',
'uri': 'https://example.com/image.jpg',
'mime_type': 'image/jpeg',
},
{
'type': 'uri',
'modality': 'audio',
'uri': 'https://example.com/audio.mp3',
'mime_type': 'audio/mpeg',
},
{
'type': 'uri',
'uri': 'https://example.com/doc.pdf',
'mime_type': 'application/pdf',
},
{
'type': 'uri',
'modality': 'video',
'uri': 'https://example.com/video.mp4',
'mime_type': 'video/mp4',
},
],
}
]
)
def test_messages_to_otel_messages_multimodal_v4_no_content():
"""Test that version 4 with include_content=False omits uri but keeps mime_type."""
messages: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(
content=[
'Describe this',
ImageUrl('https://example.com/image.jpg', media_type='image/jpeg'),
]
)
],
timestamp=IsDatetime(),
),
]
settings = deprecated_instrumentation_settings(version=4, include_content=False)
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'user',
'parts': [
{'type': 'text'},
{'type': 'uri', 'modality': 'image', 'mime_type': 'image/jpeg'},
],
}
]
)
def test_messages_to_otel_messages_binary_content_v4():
"""Test that version 4 uses blob format with modality for BinaryContent."""
image_data = BinaryContent(data=b'fake image data', media_type='image/png')
audio_data = BinaryContent(data=b'fake audio data', media_type='audio/mpeg')
video_data = BinaryContent(data=b'fake video data', media_type='video/mp4')
doc_data = BinaryContent(data=b'fake doc data', media_type='application/pdf')
messages: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(
content=[
'Analyze these files',
image_data,
audio_data,
video_data,
doc_data,
]
)
],
timestamp=IsDatetime(),
),
]
settings = deprecated_instrumentation_settings(version=4)
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'Analyze these files'},
{
'type': 'blob',
'modality': 'image',
'mime_type': 'image/png',
'content': image_data.base64,
},
{
'type': 'blob',
'modality': 'audio',
'mime_type': 'audio/mpeg',
'content': audio_data.base64,
},
{
'type': 'blob',
'modality': 'video',
'mime_type': 'video/mp4',
'content': video_data.base64,
},
{
'type': 'blob',
'mime_type': 'application/pdf',
'content': doc_data.base64,
},
],
}
]
)
def test_messages_to_otel_messages_binary_content_v4_no_content():
"""Test that version 4 with include_content=False omits content but keeps mime_type."""
image_data = BinaryContent(data=b'fake image data', media_type='image/png')
messages: list[ModelMessage] = [
ModelRequest(
parts=[UserPromptPart(content=['Analyze this', image_data])],
timestamp=IsDatetime(),
),
]
settings = deprecated_instrumentation_settings(version=4, include_content=False)
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'user',
'parts': [
{'type': 'text'},
{'type': 'blob', 'modality': 'image', 'mime_type': 'image/png'},
],
}
]
)
def test_messages_to_otel_messages_url_without_extension_v4():
"""Test that version 4 gracefully handles URLs where media_type cannot be inferred."""
# URL without extension - media_type will raise ValueError
messages: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(
content=[
'Describe this',
ImageUrl('https://example.com/image_no_extension'),
]
)
],
timestamp=IsDatetime(),
),
]
settings = deprecated_instrumentation_settings(version=4)
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'Describe this'},
{
'type': 'uri',
'modality': 'image',
'uri': 'https://example.com/image_no_extension',
# Note: mime_type is omitted because it cannot be inferred
},
],
}
]
)
def test_messages_without_content(document_content: BinaryContent):
messages: list[ModelMessage] = [
ModelRequest(parts=[SystemPromptPart('system_prompt')], timestamp=IsDatetime()),
ModelResponse(parts=[TextPart('text1')]),
ModelRequest(
parts=[
UserPromptPart(
content=[
'user_prompt1',
VideoUrl('https://example.com/video.mp4'),
ImageUrl('https://example.com/image.png'),
AudioUrl('https://example.com/audio.mp3'),
DocumentUrl('https://example.com/document.pdf'),
document_content,
]
)
],
timestamp=IsDatetime(),
),
ModelResponse(parts=[TextPart('text2'), ToolCallPart(tool_name='my_tool', args={'a': 13, 'b': 4})]),
ModelRequest(parts=[ToolReturnPart('tool', 'tool_return_content', 'tool_call_1')], timestamp=IsDatetime()),
ModelRequest(
parts=[RetryPromptPart('retry_prompt', tool_name='tool', tool_call_id='tool_call_2')],
timestamp=IsDatetime(),
),
ModelRequest(parts=[UserPromptPart(content=['user_prompt2', document_content])], timestamp=IsDatetime()),
ModelRequest(parts=[UserPromptPart('simple text prompt')], timestamp=IsDatetime()),
ModelResponse(parts=[FilePart(content=document_content)]),
]
settings = InstrumentationSettings(include_content=False)
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{'role': 'system', 'parts': [{'type': 'text'}]},
{'role': 'assistant', 'parts': [{'type': 'text'}]},
{
'role': 'user',
'parts': [
{'type': 'text'},
{'type': 'uri', 'modality': 'video', 'mime_type': 'video/mp4'},
{'type': 'uri', 'modality': 'image', 'mime_type': 'image/png'},
{'type': 'uri', 'modality': 'audio', 'mime_type': 'audio/mpeg'},
{'type': 'uri', 'mime_type': 'application/pdf'},
{'type': 'blob', 'mime_type': 'application/pdf'},
],
},
{
'role': 'assistant',
'parts': [
{'type': 'text'},
{'type': 'tool_call', 'id': IsStr(), 'name': 'my_tool'},
],
},
{'role': 'user', 'parts': [{'type': 'tool_call_response', 'id': 'tool_call_1', 'name': 'tool'}]},
{'role': 'user', 'parts': [{'type': 'tool_call_response', 'id': 'tool_call_2', 'name': 'tool'}]},
{'role': 'user', 'parts': [{'type': 'text'}, {'type': 'blob', 'mime_type': 'application/pdf'}]},
{'role': 'user', 'parts': [{'type': 'text'}]},
{'role': 'assistant', 'parts': [{'type': 'blob', 'mime_type': 'application/pdf'}]},
]
)
def test_message_with_thinking_parts():
messages: list[ModelMessage] = [
ModelResponse(parts=[TextPart('text1'), ThinkingPart('thinking1'), TextPart('text2')]),
ModelResponse(parts=[ThinkingPart('thinking2')]),
ModelResponse(parts=[ThinkingPart('thinking3'), TextPart('text3')]),
]
settings = InstrumentationSettings()
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'assistant',
'parts': [
{'type': 'text', 'content': 'text1'},
{'type': 'thinking', 'content': 'thinking1'},
{'type': 'text', 'content': 'text2'},
],
},
{'role': 'assistant', 'parts': [{'type': 'thinking', 'content': 'thinking2'}]},
{
'role': 'assistant',
'parts': [{'type': 'thinking', 'content': 'thinking3'}, {'type': 'text', 'content': 'text3'}],
},
]
)
async def test_response_cost_error(capfire: CaptureLogfire, monkeypatch: pytest.MonkeyPatch):
model = InstrumentedModel(MyModel())
messages: list[ModelMessage] = [ModelRequest(parts=[UserPromptPart('user_prompt')], timestamp=IsDatetime())]
monkeypatch.setattr(ModelResponse, 'cost', None)
with warns(
snapshot(
[
"CostCalculationFailedWarning: Failed to get cost from response: TypeError: 'NoneType' object is not callable"
]
)
):
await model.request(messages, model_settings=ModelSettings(), model_request_parameters=ModelRequestParameters())
assert capfire.exporter.exported_spans_as_dict(parse_json_attributes=True) == snapshot(
[
{
'name': 'chat gpt-4o',
'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False},
'parent': None,
'start_time': 1000000000,
'end_time': 2000000000,
'attributes': {
'gen_ai.operation.name': 'chat',
'gen_ai.provider.name': 'openai',
'gen_ai.system': 'openai',
'gen_ai.request.model': 'gpt-4o',
'server.address': 'example.com',
'server.port': 8000,
'model_request_parameters': {
'function_tools': [],
'native_tools': [],
'output_mode': 'text',
'output_object': None,
'output_tools': [],
'prompted_output_template': None,
'allow_text_output': True,
'allow_image_output': False,
'instruction_parts': None,
'thinking': None,
},
'logfire.span_type': 'span',
'logfire.msg': 'chat gpt-4o',
'gen_ai.input.messages': [{'role': 'user', 'parts': [{'type': 'text', 'content': 'user_prompt'}]}],
'gen_ai.output.messages': [
{
'role': 'assistant',
'parts': [
{'type': 'text', 'content': 'text1'},
{'type': 'tool_call', 'id': 'tool_call_1', 'name': 'tool1', 'arguments': 'args1'},
{'type': 'tool_call', 'id': 'tool_call_2', 'name': 'tool2', 'arguments': {'args2': 3}},
{'type': 'text', 'content': 'text2'},
],
}
],
'logfire.json_schema': {
'type': 'object',
'properties': {
'gen_ai.input.messages': {'type': 'array'},
'gen_ai.output.messages': {'type': 'array'},
'model_request_parameters': {'type': 'object'},
},
},
'gen_ai.usage.input_tokens': 100,
'gen_ai.usage.output_tokens': 200,
'gen_ai.usage.cache_creation.input_tokens': 10,
'gen_ai.usage.cache_read.input_tokens': 20,
'gen_ai.usage.details.reasoning_tokens': 30,
'gen_ai.usage.details.cache_write_tokens': 10,
'gen_ai.usage.details.cache_read_tokens': 20,
'gen_ai.usage.details.input_audio_tokens': 10,
'gen_ai.usage.details.cache_audio_read_tokens': 5,
'gen_ai.usage.details.output_audio_tokens': 30,
'gen_ai.response.model': 'gpt-4o-2024-11-20',
'gen_ai.response.id': 'response_id',
},
}
]
)
def test_message_with_native_tool_calls():
messages: list[ModelMessage] = [
ModelResponse(
parts=[
TextPart('text1'),
NativeToolCallPart('code_execution', {'code': '2 * 2'}, tool_call_id='tool_call_1'),
NativeToolReturnPart('code_execution', {'output': '4'}, tool_call_id='tool_call_1'),
TextPart('text2'),
NativeToolCallPart(
'web_search',
'{"query": "weather: San Francisco, CA", "type": "search"}',
tool_call_id='tool_call_2',
),
NativeToolReturnPart(
'web_search',
[
{
'url': 'https://www.weather.com/weather/today/l/USCA0987:1:US',
'title': 'Weather in San Francisco',
}
],
tool_call_id='tool_call_2',
),
TextPart('text3'),
]
),
]
settings = InstrumentationSettings()
# Built-in tool calls are only included in v2-style messages, not v1-style events,
# as the spec does not yet allow tool results coming from the assistant,
# and Logfire has special handling for the `type='tool_call_response', 'builtin=True'` messages, but not events.
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'assistant',
'parts': [
{'type': 'text', 'content': 'text1'},
{
'type': 'tool_call',
'id': 'tool_call_1',
'name': 'code_execution',
'builtin': True,
'arguments': {'code': '2 * 2'},
},
{
'type': 'tool_call_response',
'id': 'tool_call_1',
'name': 'code_execution',
'builtin': True,
'result': {'output': '4'},
},
{'type': 'text', 'content': 'text2'},
{
'type': 'tool_call',
'id': 'tool_call_2',
'name': 'web_search',
'builtin': True,
'arguments': '{"query": "weather: San Francisco, CA", "type": "search"}',
},
{
'type': 'tool_call_response',
'id': 'tool_call_2',
'name': 'web_search',
'builtin': True,
'result': [
{
'url': 'https://www.weather.com/weather/today/l/USCA0987:1:US',
'title': 'Weather in San Francisco',
}
],
},
{'type': 'text', 'content': 'text3'},
],
}
]
)
def test_cache_point_in_user_prompt():
"""Test that CachePoint is correctly skipped in OpenTelemetry conversion.
CachePoint is a marker for prompt caching and should not be included in the
OpenTelemetry message parts output.
"""
messages: list[ModelMessage] = [
ModelRequest(
parts=[UserPromptPart(content=['text before', CachePoint(), 'text after'])], timestamp=IsDatetime()
),
]
settings = InstrumentationSettings()
# Test otel_message_parts - CachePoint should be skipped
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'text before'},
{'type': 'text', 'content': 'text after'},
],
}
]
)
# Test with multiple CachePoints
messages_multi: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(content=['first', CachePoint(), 'second', CachePoint(), 'third']),
],
timestamp=IsDatetime(),
),
]
assert settings.messages_to_otel_messages(messages_multi) == snapshot(
[
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'first'},
{'type': 'text', 'content': 'second'},
{'type': 'text', 'content': 'third'},
],
}
]
)
# Test with CachePoint mixed with other content types
messages_mixed: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(
content=[
'context',
CachePoint(),
ImageUrl('https://example.com/image.jpg'),
CachePoint(),
'question',
]
),
],
timestamp=IsDatetime(),
),
]
assert settings.messages_to_otel_messages(messages_mixed) == snapshot(
[
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'context'},
{
'type': 'uri',
'modality': 'image',
'mime_type': 'image/jpeg',
'uri': 'https://example.com/image.jpg',
},
{'type': 'text', 'content': 'question'},
],
}
]
)
def test_build_tool_definitions():
"""Test build_tool_definitions with various tool configurations."""
from pydantic_ai._instrumentation import build_tool_definitions
from pydantic_ai.tools import ToolDefinition
tool_without_params = ToolDefinition(
name='no_params_tool',
description='A tool without parameters',
parameters_json_schema={},
)
tool_with_params = ToolDefinition(
name='with_params_tool',
description='A tool with parameters',
parameters_json_schema={'type': 'object', 'properties': {'x': {'type': 'integer'}}},
)
tool_no_description = ToolDefinition(
name='no_desc_tool',
description=None,
parameters_json_schema={'type': 'object', 'properties': {}},
)
params = ModelRequestParameters(
function_tools=[tool_without_params, tool_with_params, tool_no_description],
native_tools=[],
output_tools=[],
output_mode='text',
output_object=None,
prompted_output_template=None,
allow_text_output=True,
allow_image_output=False,
)
result = build_tool_definitions(params)
assert result == [
{'type': 'function', 'name': 'no_params_tool', 'description': 'A tool without parameters'},
{
'type': 'function',
'name': 'with_params_tool',
'description': 'A tool with parameters',
'parameters': {'type': 'object', 'properties': {'x': {'type': 'integer'}}},
},
{
'type': 'function',
'name': 'no_desc_tool',
'parameters': {'type': 'object', 'properties': {}},
},
]
def test_annotate_tool_call_otel_metadata():
"""`annotate_tool_call_otel_metadata` copies metadata from tool defs onto matching tool call parts."""
from pydantic_ai._instrumentation import annotate_tool_call_otel_metadata
from pydantic_ai.tools import ToolDefinition
response = ModelResponse(
parts=[
ToolCallPart(tool_name='run_code_with_tools', args={'code': 'print("hi")'}, tool_call_id='call_1'),
ToolCallPart(tool_name='other_tool', args={'x': 1}, tool_call_id='call_2'),
ToolCallPart(tool_name='unrelated_metadata_tool', args={'y': 1}, tool_call_id='call_3'),
TextPart('some text'),
]
)
params = ModelRequestParameters(
function_tools=[
ToolDefinition(
name='run_code_with_tools',
parameters_json_schema={'type': 'object', 'properties': {}},
metadata={'code_arg_name': 'code', 'code_arg_language': 'python'},
),
ToolDefinition(
name='other_tool',
parameters_json_schema={'type': 'object', 'properties': {}},
),
# Truthy metadata without `code_arg_*` keys exercises the branches that skip each
# individual `if code_arg_name`/`if code_arg_language`/`if otel_metadata` check.
ToolDefinition(
name='unrelated_metadata_tool',
parameters_json_schema={'type': 'object', 'properties': {}},
metadata={'foo': 'bar'},
),
],
native_tools=[],
output_tools=[],
output_mode='text',
output_object=None,
prompted_output_template=None,
allow_text_output=True,
allow_image_output=False,
)
annotate_tool_call_otel_metadata(response, params)
code_part = response.parts[0]
assert isinstance(code_part, ToolCallPart)
assert code_part.otel_metadata == {'code_arg_name': 'code', 'code_arg_language': 'python'}
other_part = response.parts[1]
assert isinstance(other_part, ToolCallPart)
assert other_part.otel_metadata is None
unrelated_part = response.parts[2]
assert isinstance(unrelated_part, ToolCallPart)
assert unrelated_part.otel_metadata is None
def test_builtin_code_execution_otel_metadata_in_otel_messages():
"""Builtin code execution tool calls carry code_arg metadata in OTel output."""
call_part = NativeToolCallPart(
tool_name='code_execution', args={'code': '2 * 2'}, tool_call_id='call_1', provider_name='anthropic'
)
call_part.otel_metadata = {'code_arg_name': 'code', 'code_arg_language': 'python'}
messages: list[ModelMessage] = [ModelResponse(parts=[call_part])]
settings = InstrumentationSettings()
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'assistant',
'parts': [
{
'type': 'tool_call',
'id': 'call_1',
'name': 'code_execution',
'builtin': True,
'code_arg_name': 'code',
'code_arg_language': 'python',
'arguments': {'code': '2 * 2'},
}
],
}
]
)
def test_builtin_code_execution_snippet_arg():
"""Bedrock's 'snippet' arg name is preserved in OTel output."""
call_part = NativeToolCallPart(
tool_name='code_execution', args={'snippet': '1 + 1'}, tool_call_id='call_1', provider_name='bedrock'
)
call_part.otel_metadata = {'code_arg_name': 'snippet', 'code_arg_language': 'python'}
messages: list[ModelMessage] = [ModelResponse(parts=[call_part])]
settings = InstrumentationSettings()
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'assistant',
'parts': [
{
'type': 'tool_call',
'id': 'call_1',
'name': 'code_execution',
'builtin': True,
'code_arg_name': 'snippet',
'code_arg_language': 'python',
'arguments': {'snippet': '1 + 1'},
}
],
}
]
)
def test_otel_metadata_in_otel_messages():
"""`otel_metadata` on a function tool call flows through to OTel message output."""
tool_call = ToolCallPart(tool_name='run_code_with_tools', args={'code': 'x = 1 + 2'}, tool_call_id='call_1')
tool_call.otel_metadata = {'code_arg_name': 'code', 'code_arg_language': 'python'}
messages: list[ModelMessage] = [ModelResponse(parts=[tool_call])]
settings = InstrumentationSettings()
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'assistant',
'parts': [
{
'type': 'tool_call',
'id': 'call_1',
'name': 'run_code_with_tools',
'code_arg_name': 'code',
'code_arg_language': 'python',
'arguments': {'code': 'x = 1 + 2'},
}
],
}
]
)
def test_otel_metadata_partial_only_arg_name():
"""`otel_metadata` with only `code_arg_name` set surfaces just that key."""
tool_call = ToolCallPart(tool_name='run_code', args={'code': '1+1'}, tool_call_id='call_1')
tool_call.otel_metadata = {'code_arg_name': 'code'}
messages: list[ModelMessage] = [ModelResponse(parts=[tool_call])]
settings = InstrumentationSettings()
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'assistant',
'parts': [
{
'type': 'tool_call',
'id': 'call_1',
'name': 'run_code',
'code_arg_name': 'code',
'arguments': {'code': '1+1'},
}
],
}
]
)
def test_otel_metadata_partial_only_arg_language():
"""`otel_metadata` with only `code_arg_language` set surfaces just that key."""
tool_call = ToolCallPart(tool_name='run_code', args={'code': '1+1'}, tool_call_id='call_1')
tool_call.otel_metadata = {'code_arg_language': 'python'}
messages: list[ModelMessage] = [ModelResponse(parts=[tool_call])]
settings = InstrumentationSettings()
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'assistant',
'parts': [
{
'type': 'tool_call',
'id': 'call_1',
'name': 'run_code',
'code_arg_language': 'python',
'arguments': {'code': '1+1'},
}
],
}
]
)
def test_otel_metadata_not_present_without_annotation():
"""`code_arg_name`/`code_arg_language` are absent when `otel_metadata` is not set."""
messages: list[ModelMessage] = [
ModelResponse(parts=[ToolCallPart(tool_name='some_tool', args={'x': 1}, tool_call_id='call_1')]),
]
settings = InstrumentationSettings()
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'assistant',
'parts': [
{
'type': 'tool_call',
'id': 'call_1',
'name': 'some_tool',
'arguments': {'x': 1},
}
],
}
]
)
def test_messages_to_otel_messages_file_part_v4(document_content: BinaryContent):
"""Test that version 4 uses blob format for FilePart in ModelResponse (output messages)."""
messages: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Generate a document')], timestamp=IsDatetime()),
ModelResponse(parts=[FilePart(content=document_content)]),
]
settings = deprecated_instrumentation_settings(version=4)
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'Generate a document'},
],
},
{
'role': 'assistant',
'parts': [
{
'type': 'blob',
'mime_type': 'application/pdf',
'content': document_content.base64,
},
],
},
]
)
def test_messages_to_otel_messages_file_part_v4_no_content(document_content: BinaryContent):
"""Test that version 4 with include_content=False omits content but keeps mime_type for FilePart."""
messages: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Generate a document')], timestamp=IsDatetime()),
ModelResponse(parts=[FilePart(content=document_content)]),
]
settings = deprecated_instrumentation_settings(version=4, include_content=False)
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'user',
'parts': [
{'type': 'text'},
],
},
{
'role': 'assistant',
'parts': [
{'type': 'blob', 'mime_type': 'application/pdf'},
],
},
]
)
def test_messages_to_otel_messages_cache_point_v4():
"""Test that CachePoint is correctly skipped with version 4."""
messages: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(
content=[
'text',
CachePoint(),
ImageUrl('https://example.com/image.jpg', media_type='image/jpeg'),
CachePoint(),
]
)
],
timestamp=IsDatetime(),
),
]
settings = deprecated_instrumentation_settings(version=4)
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'text'},
{
'type': 'uri',
'modality': 'image',
'mime_type': 'image/jpeg',
'uri': 'https://example.com/image.jpg',
},
],
}
]
)
def test_messages_to_otel_messages_builtin_tool_v4():
"""Test that NativeToolCallPart works correctly with version 4."""
messages: list[ModelMessage] = [
ModelResponse(
parts=[
TextPart('text'),
NativeToolCallPart('code_execution', {'code': '2 * 2'}, tool_call_id='tool_call_1'),
NativeToolReturnPart('code_execution', {'output': '4'}, tool_call_id='tool_call_1'),
]
),
]
settings = deprecated_instrumentation_settings(version=4)
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'assistant',
'parts': [
{'type': 'text', 'content': 'text'},
{
'type': 'tool_call',
'id': 'tool_call_1',
'name': 'code_execution',
'builtin': True,
'arguments': {'code': '2 * 2'},
},
{
'type': 'tool_call_response',
'id': 'tool_call_1',
'name': 'code_execution',
'builtin': True,
'result': {'output': '4'},
},
],
}
]
)
def test_messages_to_otel_messages_binary_content_v4_no_binary():
"""Test version 4 with include_binary_content=False omits the content field entirely."""
image_data = BinaryContent(data=b'fake image data', media_type='image/png')
messages: list[ModelMessage] = [
ModelRequest(
parts=[UserPromptPart(content=['Analyze this', image_data])],
timestamp=IsDatetime(),
),
]
settings = deprecated_instrumentation_settings(version=4, include_binary_content=False)
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'Analyze this'},
{'type': 'blob', 'modality': 'image', 'mime_type': 'image/png'},
],
}
]
)
def test_messages_to_otel_messages_file_part_v4_no_binary(document_content: BinaryContent):
"""Test version 4 FilePart with include_binary_content=False omits the content field."""
messages: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Generate a document')], timestamp=IsDatetime()),
ModelResponse(parts=[FilePart(content=document_content)]),
]
settings = deprecated_instrumentation_settings(version=4, include_binary_content=False)
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'Generate a document'},
],
},
{
'role': 'assistant',
'parts': [
{'type': 'blob', 'mime_type': 'application/pdf'},
],
},
]
)
def test_messages_to_otel_messages_binary_content_v4_unknown_modality():
"""Test version 4 with unknown media type (no modality field added)."""
unknown_data = BinaryContent(data=b'unknown data', media_type='x-custom/data')
messages: list[ModelMessage] = [
ModelRequest(
parts=[UserPromptPart(content=['Check this', unknown_data])],
timestamp=IsDatetime(),
),
]
settings = deprecated_instrumentation_settings(version=4)
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'Check this'},
{'type': 'blob', 'mime_type': 'x-custom/data', 'content': unknown_data.base64},
],
}
]
)
def test_messages_to_otel_messages_file_part_v4_unknown_modality():
"""Test version 4 FilePart with unknown media type (no modality field added)."""
unknown_content = BinaryContent(data=b'unknown file data', media_type='x-vendor/custom-format')
messages: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Process file')], timestamp=IsDatetime()),
ModelResponse(parts=[FilePart(content=unknown_content)]),
]
settings = deprecated_instrumentation_settings(version=4)
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'user',
'parts': [
{'type': 'text', 'content': 'Process file'},
],
},
{
'role': 'assistant',
'parts': [
{'type': 'blob', 'mime_type': 'x-vendor/custom-format', 'content': unknown_content.base64},
],
},
]
)
def test_messages_to_otel_messages_serialization_errors():
class Foo:
def __repr__(self) -> str:
return 'Foo()'
class Bar:
def __repr__(self) -> str:
raise ValueError('error!')
messages: list[ModelMessage] = [
ModelResponse(parts=[ToolCallPart('tool', {'arg': Foo()}, tool_call_id='tool_call_id')]),
ModelRequest(parts=[ToolReturnPart('tool', Bar(), tool_call_id='return_tool_call_id')], timestamp=IsDatetime()),
]
settings = InstrumentationSettings()
assert settings.messages_to_otel_messages(messages) == snapshot(
[
{
'role': 'assistant',
'parts': [{'type': 'tool_call', 'id': 'tool_call_id', 'name': 'tool', 'arguments': {'arg': 'Foo()'}}],
},
{
'role': 'user',
'parts': [
{
'type': 'tool_call_response',
'id': 'return_tool_call_id',
'name': 'tool',
'result': 'Unable to serialize: error!',
}
],
},
]
)
async def test_instrumented_model_count_tokens(capfire: CaptureLogfire):
messages: list[ModelMessage] = [ModelRequest(parts=[UserPromptPart('Hello, world!')], timestamp=IsDatetime())]
model = InstrumentedModel(MyModel())
usage = await model.count_tokens(
messages, model_settings=ModelSettings(), model_request_parameters=ModelRequestParameters()
)
assert usage == RequestUsage(input_tokens=10)
async def test_instrumented_model_with_tools_and_finish_reason(capfire: CaptureLogfire):
"""Test _instrument() with tool definitions and a response that has finish_reason."""
from pydantic_ai.tools import ToolDefinition
class FinishReasonModel(MyModel):
async def request(
self,
messages: list[ModelMessage],
model_settings: ModelSettings | None,
model_request_parameters: ModelRequestParameters,
) -> ModelResponse:
return ModelResponse(
parts=[TextPart('done')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='gpt-4o-2024-11-20',
provider_response_id='resp-123',
finish_reason='stop',
)
tool_def = ToolDefinition(
name='get_weather',
description='Get the weather',
parameters_json_schema={'type': 'object', 'properties': {'city': {'type': 'string'}}},
)
model = InstrumentedModel(FinishReasonModel())
messages: list[ModelMessage] = [ModelRequest(parts=[UserPromptPart('Hello')], timestamp=IsDatetime())]
await model.request(
messages,
model_settings=None,
model_request_parameters=ModelRequestParameters(
function_tools=[tool_def],
allow_text_output=True,
output_tools=[],
output_mode='text',
output_object=None,
),
)
spans = capfire.exporter.exported_spans_as_dict(parse_json_attributes=True)
assert len(spans) == 1
attrs = spans[0]['attributes']
# Tool definitions should be set
assert attrs['gen_ai.tool.definitions'] == snapshot(
[
{
'type': 'function',
'name': 'get_weather',
'description': 'Get the weather',
'parameters': {'type': 'object', 'properties': {'city': {'type': 'string'}}},
}
]
)
# finish_reason should be set
assert attrs['gen_ai.response.finish_reasons'] == ('stop',)
assert attrs['gen_ai.response.id'] == 'resp-123'
async def test_instrumented_model_tolerates_lone_surrogates_in_request_parameters(capfire: CaptureLogfire):
"""Lone surrogates in tool definitions / request parameters must not crash instrumentation.
`gen_ai.tool.definitions` and `model_request_parameters` are serialized on the model-request
span regardless of `include_content`; routing them through `safe_to_json` keeps a surrogate
(e.g. text decoded with `errors='surrogateescape'`) in a tool description from raising
`PydanticSerializationError` and crashing an otherwise-successful run.
"""
tool_def = ToolDefinition(name='weather', description='get the weather before\udce4after')
model = InstrumentedModel(MyModel())
messages: list[ModelMessage] = [ModelRequest(parts=[UserPromptPart('Hello')], timestamp=IsDatetime())]
await model.request(
messages,
model_settings=None,
model_request_parameters=ModelRequestParameters(function_tools=[tool_def]),
)
attrs = capfire.exporter.exported_spans_as_dict(parse_json_attributes=True)[0]['attributes']
assert attrs['gen_ai.tool.definitions'] == snapshot(
[
{
'type': 'function',
'name': 'weather',
'description': 'get the weather before\udce4after',
'parameters': {'type': 'object', 'properties': {}},
}
]
)
assert 'weather' in attrs['model_request_parameters']['function_tools'][0]['name']
async def test_instrumented_model_request_error(capfire: CaptureLogfire):
"""Test _instrument() when the wrapped model raises before finish() is called."""
class ErrorModel(MyModel):
async def request(
self,
messages: list[ModelMessage],
model_settings: ModelSettings | None,
model_request_parameters: ModelRequestParameters,
) -> ModelResponse:
raise RuntimeError('model error')
model = InstrumentedModel(ErrorModel())
messages: list[ModelMessage] = [ModelRequest(parts=[UserPromptPart('Hello')], timestamp=IsDatetime())]
with pytest.raises(RuntimeError, match='model error'):
await model.request(
messages,
model_settings=None,
model_request_parameters=ModelRequestParameters(),
)
# Span should still be created, but without finish()-specific attributes
spans = capfire.exporter.exported_spans_as_dict(parse_json_attributes=True)
assert len(spans) == 1
assert spans[0]['attributes']['gen_ai.request.model'] == 'gpt-4o'
# finish() was never called, so response-specific attributes are absent
assert 'gen_ai.response.id' not in spans[0]['attributes']
assert 'gen_ai.usage.input_tokens' not in spans[0]['attributes']