import json
import re
import sys
import warnings
from dataclasses import replace
from datetime import datetime, timezone
from pathlib import Path
from typing import Annotated, Any, cast, get_args, get_origin
import pytest
from pydantic import TypeAdapter
from pydantic_ai import (
Agent,
AgentStreamEvent,
AudioUrl,
BinaryContent,
BinaryImage,
DocumentUrl,
FilePart,
ImageUrl,
InstructionPart,
InstrumentationSettings,
ModelMessage,
ModelMessagesTypeAdapter,
ModelRequest,
ModelResponse,
MultiModalContent,
NativeToolCallPart,
NativeToolReturnPart,
PartDeltaEvent,
RequestUsage,
RetryPromptPart,
TextContent,
TextPart,
ThinkingPart,
ThinkingPartDelta,
ToolCallPart,
ToolReturnPart,
UploadedFile,
UserPromptPart,
VideoUrl,
)
from pydantic_ai._parts_manager import ModelResponsePartsManager
from pydantic_ai.messages import (
INVALID_JSON_KEY,
MULTI_MODAL_CONTENT_TYPES,
LoadCapabilityCallPart,
LoadCapabilityReturnPart,
ToolReturnContent,
is_multi_modal_content,
narrow_message_parts,
)
from pydantic_ai.models import ModelRequestParameters
from pydantic_ai.models.test import TestModel
from ._inline_snapshot import snapshot
from .conftest import IsDatetime, IsNow, IsStr, message, message_part
def test_image_url():
image_url = ImageUrl(url='https://example.com/image.jpg')
assert image_url.media_type == 'image/jpeg'
assert image_url.format == 'jpeg'
image_url = ImageUrl(url='https://example.com/image', media_type='image/jpeg')
assert image_url.media_type == 'image/jpeg'
assert image_url.format == 'jpeg'
def test_video_url():
video_url = VideoUrl(url='https://example.com/video.mp4')
assert video_url.media_type == 'video/mp4'
assert video_url.format == 'mp4'
video_url = VideoUrl(url='https://example.com/video', media_type='video/mp4')
assert video_url.media_type == 'video/mp4'
assert video_url.format == 'mp4'
@pytest.mark.parametrize(
'url,is_youtube',
[
pytest.param('https://youtu.be/lCdaVNyHtjU', True, id='youtu.be'),
pytest.param('https://www.youtube.com/lCdaVNyHtjU', True, id='www.youtube.com'),
pytest.param('https://youtube.com/lCdaVNyHtjU', True, id='youtube.com'),
pytest.param('https://dummy.com/video.mp4', False, id='dummy.com'),
],
)
def test_youtube_video_url(url: str, is_youtube: bool):
video_url = VideoUrl(url=url)
assert video_url.is_youtube is is_youtube
assert video_url.media_type == 'video/mp4'
assert video_url.format == 'mp4'
@pytest.mark.parametrize(
'url, expected_data_type',
[
('https://raw.githubusercontent.com/pydantic/pydantic-ai/refs/heads/main/docs/help.md', 'text/markdown'),
('https://raw.githubusercontent.com/pydantic/pydantic-ai/refs/heads/main/docs/help.txt', 'text/plain'),
('https://raw.githubusercontent.com/pydantic/pydantic-ai/refs/heads/main/docs/help.pdf', 'application/pdf'),
('https://raw.githubusercontent.com/pydantic/pydantic-ai/refs/heads/main/docs/help.rtf', 'application/rtf'),
(
'https://raw.githubusercontent.com/pydantic/pydantic-ai/refs/heads/main/docs/help.asciidoc',
'text/x-asciidoc',
),
],
)
def test_document_url_other_types(url: str, expected_data_type: str) -> None:
document_url = DocumentUrl(url=url)
assert document_url.media_type == expected_data_type
def test_document_url():
document_url = DocumentUrl(url='https://example.com/document.pdf')
assert document_url.media_type == 'application/pdf'
assert document_url.format == 'pdf'
document_url = DocumentUrl(url='https://example.com/document', media_type='application/pdf')
assert document_url.media_type == 'application/pdf'
assert document_url.format == 'pdf'
def test_text_content():
text_content = TextContent(content='Pydantic AI!', metadata={'foo': 'bar'})
assert text_content.content == 'Pydantic AI!'
assert text_content.metadata == {'foo': 'bar'}
@pytest.mark.parametrize(
'media_type, format',
[
('audio/wav', 'wav'),
('audio/mpeg', 'mp3'),
],
)
def test_binary_content_audio(media_type: str, format: str):
binary_content = BinaryContent(data=b'Hello, world!', media_type=media_type)
assert binary_content.is_audio
assert binary_content.format == format
@pytest.mark.parametrize(
'media_type, format',
[
('image/jpeg', 'jpeg'),
('image/png', 'png'),
('image/gif', 'gif'),
('image/webp', 'webp'),
],
)
def test_binary_content_image(media_type: str, format: str):
binary_content = BinaryContent(data=b'Hello, world!', media_type=media_type)
assert binary_content.is_image
assert binary_content.format == format
def test_binary_image_requires_image_media_type():
# Valid image media type should work
img = BinaryImage(data=b'test', media_type='image/png')
assert img.is_image
# Non-image media type should raise
with pytest.raises(ValueError, match='`BinaryImage` must have a media type that starts with "image/"'):
BinaryImage(data=b'test', media_type='text/plain')
@pytest.mark.parametrize(
'media_type, format',
[
('video/x-matroska', 'mkv'),
('video/quicktime', 'mov'),
('video/mp4', 'mp4'),
('video/webm', 'webm'),
('video/x-flv', 'flv'),
('video/mpeg', 'mpeg'),
('video/x-ms-wmv', 'wmv'),
('video/3gpp', 'three_gp'),
],
)
def test_binary_content_video(media_type: str, format: str):
binary_content = BinaryContent(data=b'Hello, world!', media_type=media_type)
assert binary_content.is_video
assert binary_content.format == format
@pytest.mark.parametrize(
'media_type, format',
[
('application/pdf', 'pdf'),
('text/plain', 'txt'),
('text/csv', 'csv'),
('application/msword', 'doc'),
('application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'docx'),
('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'xlsx'),
('text/html', 'html'),
('text/markdown', 'md'),
('application/vnd.ms-excel', 'xls'),
],
)
def test_binary_content_document(media_type: str, format: str):
binary_content = BinaryContent(data=b'Hello, world!', media_type=media_type)
assert binary_content.is_document
assert binary_content.format == format
@pytest.mark.parametrize(
'audio_url,media_type,format',
[
pytest.param(AudioUrl('foobar.mp3'), 'audio/mpeg', 'mp3', id='mp3'),
pytest.param(AudioUrl('foobar.wav'), 'audio/wav', 'wav', id='wav'),
pytest.param(AudioUrl('foobar.oga'), 'audio/ogg', 'oga', id='oga'),
pytest.param(AudioUrl('foobar.flac'), 'audio/flac', 'flac', id='flac'),
pytest.param(AudioUrl('foobar.aiff'), 'audio/aiff', 'aiff', id='aiff'),
pytest.param(AudioUrl('foobar.aac'), 'audio/aac', 'aac', id='aac'),
pytest.param(AudioUrl('foobar', media_type='audio/mpeg'), 'audio/mpeg', 'mp3', id='mp3'),
],
)
def test_audio_url(audio_url: AudioUrl, media_type: str, format: str):
assert audio_url.media_type == media_type
assert audio_url.format == format
def test_audio_url_invalid():
with pytest.raises(ValueError, match=re.escape('Could not infer media type from audio URL: foobar.potato')):
AudioUrl('foobar.potato').media_type
@pytest.mark.parametrize(
'image_url,media_type,format',
[
pytest.param(ImageUrl('foobar.jpg'), 'image/jpeg', 'jpeg', id='jpg'),
pytest.param(ImageUrl('foobar.jpeg'), 'image/jpeg', 'jpeg', id='jpeg'),
pytest.param(ImageUrl('foobar.png'), 'image/png', 'png', id='png'),
pytest.param(ImageUrl('foobar.gif'), 'image/gif', 'gif', id='gif'),
pytest.param(ImageUrl('foobar.webp'), 'image/webp', 'webp', id='webp'),
],
)
def test_image_url_formats(image_url: ImageUrl, media_type: str, format: str):
assert image_url.media_type == media_type
assert image_url.format == format
def test_image_url_invalid():
with pytest.raises(ValueError, match=re.escape('Could not infer media type from image URL: foobar.potato')):
ImageUrl('foobar.potato').media_type
with pytest.raises(ValueError, match=re.escape('Could not infer media type from image URL: foobar.potato')):
ImageUrl('foobar.potato').format
_url_formats = [
pytest.param(DocumentUrl('foobar.pdf'), 'application/pdf', 'pdf', id='pdf'),
pytest.param(DocumentUrl('foobar.txt'), 'text/plain', 'txt', id='txt'),
pytest.param(DocumentUrl('foobar.csv'), 'text/csv', 'csv', id='csv'),
pytest.param(DocumentUrl('foobar.doc'), 'application/msword', 'doc', id='doc'),
pytest.param(
DocumentUrl('foobar.docx'),
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'docx',
id='docx',
),
pytest.param(
DocumentUrl('foobar.xlsx'),
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'xlsx',
id='xlsx',
),
pytest.param(DocumentUrl('foobar.html'), 'text/html', 'html', id='html'),
pytest.param(DocumentUrl('foobar.xls'), 'application/vnd.ms-excel', 'xls', id='xls'),
]
if sys.version_info > (3, 11): # pragma: no branch
# This solves an issue with MIMEType on MacOS + python < 3.12. mimetypes.py added the text/markdown in 3.12, but on
# versions of linux the knownfiles include text/markdown so it isn't an issue. The .md test is only consistent
# independent of OS on > 3.11.
_url_formats.append(pytest.param(DocumentUrl('foobar.md'), 'text/markdown', 'md', id='md'))
@pytest.mark.parametrize('document_url,media_type,format', _url_formats)
def test_document_url_formats(document_url: DocumentUrl, media_type: str, format: str):
assert document_url.media_type == media_type
assert document_url.format == format
def test_document_url_invalid():
with pytest.raises(ValueError, match=re.escape('Could not infer media type from document URL: foobar.potato')):
DocumentUrl('foobar.potato').media_type
with pytest.raises(ValueError, match='Unknown document media type: text/x-python'):
DocumentUrl('foobar.py').format
def test_binary_content_unknown_media_type():
with pytest.raises(ValueError, match='Unknown media type: application/custom'):
binary_content = BinaryContent(data=b'Hello, world!', media_type='application/custom')
binary_content.format
def test_binary_content_is_methods():
# Test that is_X returns False for non-matching media types
audio_content = BinaryContent(data=b'Hello, world!', media_type='audio/wav')
assert audio_content.is_audio is True
assert audio_content.is_image is False
assert audio_content.is_video is False
assert audio_content.is_document is False
assert audio_content.format == 'wav'
audio_content = BinaryContent(data=b'Hello, world!', media_type='audio/wrong')
assert audio_content.is_audio is True
assert audio_content.is_image is False
assert audio_content.is_video is False
assert audio_content.is_document is False
with pytest.raises(ValueError, match='Unknown media type: audio/wrong'):
audio_content.format
audio_content = BinaryContent(data=b'Hello, world!', media_type='image/wrong')
assert audio_content.is_audio is False
assert audio_content.is_image is True
assert audio_content.is_video is False
assert audio_content.is_document is False
with pytest.raises(ValueError, match='Unknown media type: image/wrong'):
audio_content.format
image_content = BinaryContent(data=b'Hello, world!', media_type='image/jpeg')
assert image_content.is_audio is False
assert image_content.is_image is True
assert image_content.is_video is False
assert image_content.is_document is False
assert image_content.format == 'jpeg'
video_content = BinaryContent(data=b'Hello, world!', media_type='video/mp4')
assert video_content.is_audio is False
assert video_content.is_image is False
assert video_content.is_video is True
assert video_content.is_document is False
assert video_content.format == 'mp4'
video_content = BinaryContent(data=b'Hello, world!', media_type='video/wrong')
assert video_content.is_audio is False
assert video_content.is_image is False
assert video_content.is_video is True
assert video_content.is_document is False
with pytest.raises(ValueError, match='Unknown media type: video/wrong'):
video_content.format
document_content = BinaryContent(data=b'Hello, world!', media_type='application/pdf')
assert document_content.is_audio is False
assert document_content.is_image is False
assert document_content.is_video is False
assert document_content.is_document is True
assert document_content.format == 'pdf'
def test_binary_content_base64():
bc = BinaryContent(data=b'Hello, world!', media_type='image/png')
assert bc.base64 == 'SGVsbG8sIHdvcmxkIQ=='
assert not bc.base64.startswith('data:')
assert bc.data_uri == 'data:image/png;base64,SGVsbG8sIHdvcmxkIQ=='
def test_from_data_uri_base64():
bc = BinaryContent.from_data_uri('data:image/png;base64,SGVsbG8sIHdvcmxkIQ==')
assert bc.data == b'Hello, world!'
assert bc.media_type == 'image/png'
def test_from_data_uri_non_base64():
with pytest.raises(ValueError, match='must be base64-encoded'):
BinaryContent.from_data_uri('data:text/plain,Hello%20World')
@pytest.mark.xdist_group(name='url_formats')
@pytest.mark.parametrize(
'video_url,media_type,format',
[
pytest.param(VideoUrl('foobar.mp4'), 'video/mp4', 'mp4', id='mp4'),
pytest.param(VideoUrl('foobar.mov'), 'video/quicktime', 'mov', id='mov'),
pytest.param(VideoUrl('foobar.mkv'), 'video/x-matroska', 'mkv', id='mkv'),
pytest.param(VideoUrl('foobar.webm'), 'video/webm', 'webm', id='webm'),
pytest.param(VideoUrl('foobar.flv'), 'video/x-flv', 'flv', id='flv'),
pytest.param(VideoUrl('foobar.mpeg'), 'video/mpeg', 'mpeg', id='mpeg'),
pytest.param(VideoUrl('foobar.wmv'), 'video/x-ms-wmv', 'wmv', id='wmv'),
pytest.param(VideoUrl('foobar.three_gp'), 'video/3gpp', 'three_gp', id='three_gp'),
],
)
def test_video_url_formats(video_url: VideoUrl, media_type: str, format: str):
assert video_url.media_type == media_type
assert video_url.format == format
def test_video_url_invalid():
with pytest.raises(ValueError, match=re.escape('Could not infer media type from video URL: foobar.potato')):
VideoUrl('foobar.potato').media_type
@pytest.mark.skipif(
sys.version_info < (3, 11), reason="'Python 3.10's mimetypes module does not support query parameters'"
)
def test_url_with_query_parameters() -> None:
"""Test that Url types correctly infer media type from URLs with query parameters"""
video_url = VideoUrl('https://example.com/video.mp4?query=param')
assert video_url.media_type == 'video/mp4'
assert video_url.format == 'mp4'
def test_thinking_part_delta_apply_to_thinking_part_delta():
"""Test lines 768-775: Apply ThinkingPartDelta to another ThinkingPartDelta."""
original_delta = ThinkingPartDelta(
content_delta='original',
signature_delta='sig1',
provider_name='original_provider',
provider_details={'foo': 'bar', 'baz': 'qux'},
)
# Test applying delta with no content or signature - should raise error
empty_delta = ThinkingPartDelta()
with pytest.raises(ValueError, match='Cannot apply ThinkingPartDelta with no content or signature'):
empty_delta.apply(original_delta)
# Test applying delta with content_delta
content_delta = ThinkingPartDelta(content_delta=' new_content')
result = content_delta.apply(original_delta)
assert isinstance(result, ThinkingPartDelta)
assert result.content_delta == 'original new_content'
# Test applying delta with signature_delta
sig_delta = ThinkingPartDelta(signature_delta='new_sig')
result = sig_delta.apply(original_delta)
assert isinstance(result, ThinkingPartDelta)
assert result.signature_delta == 'new_sig'
# Test applying delta with provider_name
content_delta = ThinkingPartDelta(content_delta='', provider_name='new_provider')
result = content_delta.apply(original_delta)
assert isinstance(result, ThinkingPartDelta)
assert result.provider_name == 'new_provider'
# Test applying delta with provider_details
provider_details_delta = ThinkingPartDelta(
content_delta='', provider_details={'finish_reason': 'STOP', 'foo': 'qux'}
)
result = provider_details_delta.apply(original_delta)
assert isinstance(result, ThinkingPartDelta)
assert result.provider_details == {'foo': 'qux', 'baz': 'qux', 'finish_reason': 'STOP'}
# Test chaining callable provider_details in delta-to-delta
delta1 = ThinkingPartDelta(
content_delta='first',
provider_details=lambda d: {**(d or {}), 'first': 1},
)
delta2 = ThinkingPartDelta(
content_delta=' second',
provider_details=lambda d: {**(d or {}), 'second': 2},
)
chained = delta2.apply(delta1)
assert isinstance(chained, ThinkingPartDelta)
assert callable(chained.provider_details)
# Apply chained delta to actual ThinkingPart to verify both callables ran
part = ThinkingPart(content='')
result_part = chained.apply(part)
assert result_part.provider_details == {'first': 1, 'second': 2}
# Test applying dict delta to callable delta (dict should merge with callable result)
delta_callable = ThinkingPartDelta(
content_delta='callable',
provider_details=lambda d: {**(d or {}), 'from_callable': 'yes'},
)
delta_dict = ThinkingPartDelta(
content_delta=' dict',
provider_details={'from_dict': 'also'},
)
chained = delta_dict.apply(delta_callable)
assert isinstance(chained, ThinkingPartDelta)
assert callable(chained.provider_details)
part = ThinkingPart(content='')
result_part = chained.apply(part)
assert result_part.provider_details == {'from_callable': 'yes', 'from_dict': 'also'}
def test_thinking_part_delta_callable_provider_details_serializable():
# Reproduce the real streaming path: OpenAI's gpt-oss raw-CoT handler passes a callable
# `provider_details` to `handle_thinking_delta`, which emits it verbatim inside a `PartDeltaEvent`
# (see `_make_raw_content_updater` in models/openai.py). Such an event must still serialize, e.g.
# when crossing a Temporal activity boundary in durable execution.
manager = ModelResponsePartsManager(model_request_parameters=ModelRequestParameters())
list(manager.handle_thinking_delta(vendor_part_id='t', content='reasoning', provider_details={'raw_content': ['']}))
def update_details(existing: dict[str, Any] | None) -> dict[str, Any]:
details = {**(existing or {})}
details['raw_content'] = [*details.get('raw_content', []), 'tok']
return details
events = list(manager.handle_thinking_delta(vendor_part_id='t', content=' more', provider_details=update_details))
assert len(events) == 1
event = events[0]
assert isinstance(event, PartDeltaEvent)
assert isinstance(event.delta, ThinkingPartDelta)
assert callable(event.delta.provider_details)
adapter: TypeAdapter[AgentStreamEvent] = TypeAdapter(AgentStreamEvent)
# The callable merge callback can't be JSON-serialized, so it is emitted as `null` instead of raising.
serialized = adapter.dump_json(event)
assert json.loads(serialized)['delta']['provider_details'] is None
# The serialized event round-trips back into an `AgentStreamEvent`.
assert isinstance(adapter.validate_json(serialized), PartDeltaEvent)
# Serialization is scoped to JSON mode, so Python-mode `model_dump()` keeps the callable intact.
assert callable(adapter.dump_python(event)['delta']['provider_details'])
# A plain dict `provider_details` is preserved as-is.
dict_event = PartDeltaEvent(
index=0,
delta=ThinkingPartDelta(content_delta='dict', provider_details={'provider': 'detail'}),
)
assert json.loads(adapter.dump_json(dict_event))['delta']['provider_details'] == {'provider': 'detail'}
def test_pre_usage_refactor_messages_deserializable():
# https://github.com/pydantic/pydantic-ai/pull/2378 changed the `ModelResponse` fields,
# but we as tell people to store those in the DB we want to be very careful not to break deserialization.
data = [
{
'parts': [
{
'content': 'What is the capital of Mexico?',
'timestamp': datetime.now(tz=timezone.utc),
'part_kind': 'user-prompt',
}
],
'instructions': None,
'kind': 'request',
},
{
'parts': [{'content': 'Mexico City.', 'part_kind': 'text'}],
'usage': {
'requests': 1,
'request_tokens': 13,
'response_tokens': 76,
'total_tokens': 89,
'details': None,
},
'model_name': 'gpt-5-2025-08-07',
'timestamp': datetime.now(tz=timezone.utc),
'kind': 'response',
'vendor_details': {
'finish_reason': 'STOP',
},
'vendor_id': 'chatcmpl-CBpEXeCfDAW4HRcKQwbqsRDn7u7C5',
},
]
messages = ModelMessagesTypeAdapter.validate_python(data)
assert messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='What is the capital of Mexico?',
timestamp=IsNow(tz=timezone.utc),
)
],
),
ModelResponse(
parts=[TextPart(content='Mexico City.')],
usage=RequestUsage(
input_tokens=13,
output_tokens=76,
details={},
),
model_name='gpt-5-2025-08-07',
timestamp=IsNow(tz=timezone.utc),
provider_details={'finish_reason': 'STOP'},
provider_response_id='chatcmpl-CBpEXeCfDAW4HRcKQwbqsRDn7u7C5',
),
]
)
@pytest.mark.anyio
async def test_legacy_vendor_message_history_replays_through_agent():
"""1.x message history serialized with `vendor_details` / `vendor_id` keys still routes through `agent.run(message_history=...)`.
Backstop for the V2-RULES rule 4 (cross-history-replay): the deprecated `vendor_*` read properties
are gone in v2, but the validation aliases on `provider_details` / `provider_response_id` stay so
stored histories load.
"""
legacy_history: list[dict[str, Any]] = [
{
'parts': [{'content': 'Hi', 'part_kind': 'user-prompt'}],
'kind': 'request',
},
{
'parts': [{'content': 'Hello!', 'part_kind': 'text'}],
'kind': 'response',
'model_name': 'gpt-5',
'provider_name': 'openai',
'vendor_details': {'finish_reason': 'stop'},
'vendor_id': 'chatcmpl-legacy',
},
]
message_history = ModelMessagesTypeAdapter.validate_python(legacy_history)
response = next(m for m in message_history if isinstance(m, ModelResponse))
assert response.provider_details == {'finish_reason': 'stop'}
assert response.provider_response_id == 'chatcmpl-legacy'
agent = Agent(TestModel())
result = await agent.run('And now?', message_history=message_history)
replayed_response = next(
m for m in result.all_messages() if isinstance(m, ModelResponse) and m.model_name == 'gpt-5'
)
assert replayed_response.provider_details == {'finish_reason': 'stop'}
assert replayed_response.provider_response_id == 'chatcmpl-legacy'
def test_file_part_has_content():
filepart = FilePart(content=BinaryContent(data=b'', media_type='application/pdf'))
assert not filepart.has_content()
filepart.content.data = b'not empty'
assert filepart.has_content()
@pytest.mark.parametrize(
'args',
[
{'key': 'value'},
{'key': 0},
{'key': False},
{'key': ''},
{'key': []},
{'key': {}},
'{"key": "value"}',
'0',
],
)
def test_tool_call_part_has_content(args: dict[str, object] | str):
part = ToolCallPart(tool_name='test_tool', args=args)
assert part.has_content()
@pytest.mark.parametrize(
'args',
[
{},
'',
None,
],
)
def test_tool_call_part_has_content_empty(args: dict[str, object] | str | None):
part = ToolCallPart(tool_name='test_tool', args=args)
assert not part.has_content()
@pytest.mark.parametrize(
'args',
[
{'key': 'value'},
{'key': 0},
{'key': False},
],
)
def test_builtin_tool_call_part_has_content(args: dict[str, object] | str | None):
part = NativeToolCallPart(tool_name='web_search', args=args)
assert part.has_content()
@pytest.mark.parametrize(
'args',
[
{},
None,
],
)
def test_builtin_tool_call_part_has_content_empty(args: dict[str, object] | str | None):
part = NativeToolCallPart(tool_name='web_search', args=args)
assert not part.has_content()
def test_file_part_serialization_roundtrip():
# Verify that a serialized BinaryImage doesn't come back as a BinaryContent.
messages: list[ModelMessage] = [
ModelResponse(parts=[FilePart(content=BinaryImage(data=b'fake', media_type='image/jpeg'))])
]
serialized = ModelMessagesTypeAdapter.dump_python(messages, mode='json')
assert serialized == snapshot(
[
{
'parts': [
{
'content': {
'data': 'ZmFrZQ==',
'media_type': 'image/jpeg',
'identifier': 'c053ec',
'vendor_metadata': None,
'kind': 'binary',
},
'id': None,
'provider_name': None,
'part_kind': 'file',
'provider_details': None,
}
],
'usage': {
'input_tokens': 0,
'cache_write_tokens': 0,
'cache_read_tokens': 0,
'output_tokens': 0,
'input_audio_tokens': 0,
'cache_audio_read_tokens': 0,
'output_audio_tokens': 0,
'details': {},
},
'model_name': None,
'timestamp': IsStr(),
'kind': 'response',
'provider_name': None,
'provider_url': None,
'provider_details': None,
'provider_response_id': None,
'finish_reason': None,
'run_id': None,
'conversation_id': None,
'metadata': None,
'state': 'complete',
}
]
)
deserialized = ModelMessagesTypeAdapter.validate_python(serialized)
assert deserialized == messages
def test_model_messages_type_adapter_preserves_run_id():
messages: list[ModelMessage] = [
ModelRequest(
parts=[UserPromptPart(content='Hi there', timestamp=datetime.now(tz=timezone.utc))],
run_id='run-123',
metadata={'key': 'value'},
),
ModelResponse(parts=[TextPart(content='Hello!')], run_id='run-123', metadata={'key': 'value'}),
]
serialized = ModelMessagesTypeAdapter.dump_python(messages, mode='python')
deserialized = ModelMessagesTypeAdapter.validate_python(serialized)
assert [message.run_id for message in deserialized] == snapshot(['run-123', 'run-123'])
def test_model_messages_type_adapter_preserves_conversation_id():
messages: list[ModelMessage] = [
ModelRequest(
parts=[UserPromptPart(content='Hi there', timestamp=datetime.now(tz=timezone.utc))],
conversation_id='conv-abc',
),
ModelResponse(parts=[TextPart(content='Hello!')], conversation_id='conv-abc'),
]
serialized = ModelMessagesTypeAdapter.dump_python(messages, mode='python')
deserialized = ModelMessagesTypeAdapter.validate_python(serialized)
assert [message.conversation_id for message in deserialized] == snapshot(['conv-abc', 'conv-abc'])
def test_model_messages_type_adapter_back_compat_missing_conversation_id():
"""Histories serialized before the field existed should deserialize with conversation_id=None."""
pre_pr_serialized = [
{
'kind': 'request',
'parts': [{'part_kind': 'user-prompt', 'content': 'Hello'}],
'run_id': 'run-123',
},
{
'kind': 'response',
'parts': [{'part_kind': 'text', 'content': 'Hi'}],
'run_id': 'run-123',
},
]
deserialized = ModelMessagesTypeAdapter.validate_python(pre_pr_serialized)
assert all(m.conversation_id is None for m in deserialized)
def test_model_messages_type_adapter_preserves_user_text_prompt_metadata():
messages: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(
content=[TextContent(content='What is the weather like today?', metadata={'foo': 'bar'})],
timestamp=datetime.now(tz=timezone.utc),
)
],
run_id='run-123',
metadata={'key': 'value'},
)
]
serialized = ModelMessagesTypeAdapter.dump_python(messages, mode='python')
deserialized = ModelMessagesTypeAdapter.validate_python(serialized)
assert deserialized[0].parts[0].content[0].metadata == snapshot({'foo': 'bar'}) # type: ignore[reportUnknownMemberType]
def test_model_response_convenience_methods():
response = ModelResponse(parts=[])
assert response.text == snapshot(None)
assert response.thinking == snapshot(None)
assert response.files == snapshot([])
assert response.images == snapshot([])
assert response.tool_calls == snapshot([])
assert response.native_tool_calls == snapshot([])
response = ModelResponse(
parts=[
ThinkingPart(content="Let's generate an image"),
ThinkingPart(content="And then, call the 'hello_world' tool"),
TextPart(content="I'm going to"),
TextPart(content=' generate an image'),
NativeToolCallPart(tool_name='image_generation', args={}, tool_call_id='123'),
FilePart(content=BinaryImage(data=b'fake', media_type='image/jpeg')),
NativeToolReturnPart(tool_name='image_generation', content={}, tool_call_id='123'),
TextPart(content="I'm going to call"),
TextPart(content=" the 'hello_world' tool"),
ToolCallPart(tool_name='hello_world', args={}, tool_call_id='123'),
]
)
assert response.text == snapshot("""\
I'm going to generate an image
I'm going to call the 'hello_world' tool\
""")
assert response.thinking == snapshot("""\
Let's generate an image
And then, call the 'hello_world' tool\
""")
assert response.files == snapshot([BinaryImage(data=b'fake', media_type='image/jpeg', identifier='c053ec')])
assert response.images == snapshot([BinaryImage(data=b'fake', media_type='image/jpeg', identifier='c053ec')])
assert response.tool_calls == snapshot([ToolCallPart(tool_name='hello_world', args={}, tool_call_id='123')])
assert response.native_tool_calls == snapshot(
[
(
NativeToolCallPart(tool_name='image_generation', args={}, tool_call_id='123'),
NativeToolReturnPart(
tool_name='image_generation',
content={},
tool_call_id='123',
timestamp=IsDatetime(),
),
)
]
)
def test_image_url_validation_with_optional_identifier():
image_url_ta = TypeAdapter(ImageUrl)
image = image_url_ta.validate_python({'url': 'https://example.com/image.jpg'})
assert image.url == snapshot('https://example.com/image.jpg')
assert image.identifier == snapshot('39cfc4')
assert image.media_type == snapshot('image/jpeg')
assert image_url_ta.dump_python(image) == snapshot(
{
'url': 'https://example.com/image.jpg',
'force_download': False,
'vendor_metadata': None,
'kind': 'image-url',
'media_type': 'image/jpeg',
'identifier': '39cfc4',
}
)
image = image_url_ta.validate_python(
{'url': 'https://example.com/image.jpg', 'identifier': 'foo', 'media_type': 'image/png'}
)
assert image.url == snapshot('https://example.com/image.jpg')
assert image.identifier == snapshot('foo')
assert image.media_type == snapshot('image/png')
assert image_url_ta.dump_python(image) == snapshot(
{
'url': 'https://example.com/image.jpg',
'force_download': False,
'vendor_metadata': None,
'kind': 'image-url',
'media_type': 'image/png',
'identifier': 'foo',
}
)
def test_binary_content_validation_with_optional_identifier():
binary_content_ta = TypeAdapter(BinaryContent)
binary_content = binary_content_ta.validate_python({'data': b'fake', 'media_type': 'image/jpeg'})
assert binary_content.data == b'fake'
assert binary_content.identifier == snapshot('c053ec')
assert binary_content.media_type == snapshot('image/jpeg')
assert binary_content_ta.dump_python(binary_content) == snapshot(
{
'data': b'fake',
'vendor_metadata': None,
'kind': 'binary',
'media_type': 'image/jpeg',
'identifier': 'c053ec',
}
)
binary_content = binary_content_ta.validate_python(
{'data': b'fake', 'identifier': 'foo', 'media_type': 'image/png'}
)
assert binary_content.data == b'fake'
assert binary_content.identifier == snapshot('foo')
assert binary_content.media_type == snapshot('image/png')
assert binary_content_ta.dump_python(binary_content) == snapshot(
{
'data': b'fake',
'vendor_metadata': None,
'kind': 'binary',
'media_type': 'image/png',
'identifier': 'foo',
}
)
def test_binary_content_from_path(tmp_path: Path):
# test normal file
test_xml_file = tmp_path / 'test.xml'
test_xml_file.write_text('about trains', encoding='utf-8')
binary_content = BinaryContent.from_path(test_xml_file)
assert binary_content == snapshot(BinaryContent(data=b'about trains', media_type='application/xml'))
# test non-existent file
non_existent_file = tmp_path / 'non-existent.txt'
with pytest.raises(FileNotFoundError, match='File not found:'):
BinaryContent.from_path(non_existent_file)
# test file with unknown media type
test_unknown_file = tmp_path / 'test.unknownext'
test_unknown_file.write_text('some content', encoding='utf-8')
binary_content = BinaryContent.from_path(test_unknown_file)
assert binary_content == snapshot(BinaryContent(data=b'some content', media_type='application/octet-stream'))
# test string path
test_txt_file = tmp_path / 'test.txt'
test_txt_file.write_text('just some text', encoding='utf-8')
string_path = test_txt_file.as_posix()
binary_content = BinaryContent.from_path(string_path) # pyright: ignore[reportArgumentType]
assert binary_content == snapshot(BinaryContent(data=b'just some text', media_type='text/plain'))
# test image file
test_jpg_file = tmp_path / 'test.jpg'
test_jpg_file.write_bytes(b'\xff\xd8\xff\xe0' + b'0' * 100) # minimal JPEG header + padding
binary_content = BinaryContent.from_path(test_jpg_file)
assert binary_content == snapshot(
BinaryImage(data=b'\xff\xd8\xff\xe0' + b'0' * 100, media_type='image/jpeg', _identifier='bc8d49')
)
# test yaml file
test_yaml_file = tmp_path / 'config.yaml'
test_yaml_file.write_text('key: value', encoding='utf-8')
binary_content = BinaryContent.from_path(test_yaml_file)
assert binary_content == snapshot(BinaryContent(data=b'key: value', media_type='application/yaml'))
# test yml file (alternative extension)
test_yml_file = tmp_path / 'docker-compose.yml'
test_yml_file.write_text('version: "3"', encoding='utf-8')
binary_content = BinaryContent.from_path(test_yml_file)
assert binary_content == snapshot(BinaryContent(data=b'version: "3"', media_type='application/yaml'))
# test toml file
test_toml_file = tmp_path / 'pyproject.toml'
test_toml_file.write_text('[project]\nname = "test"', encoding='utf-8')
binary_content = BinaryContent.from_path(test_toml_file)
assert binary_content == snapshot(BinaryContent(data=b'[project]\nname = "test"', media_type='application/toml'))
def test_uploaded_file_identifier_property():
"""Test that UploadedFile.identifier hashes the file_id."""
# Test basic identifier (should be hashed)
uploaded_file = UploadedFile(file_id='file-abc123', provider_name='anthropic')
assert uploaded_file.identifier == snapshot('3a1a6c')
# Test with custom identifier
uploaded_file_with_id = UploadedFile(file_id='file-xyz789', provider_name='anthropic', identifier='my-custom-id')
assert uploaded_file_with_id.identifier == 'my-custom-id'
# Test with URL file_id (should still be hashed)
uploaded_file_url = UploadedFile(
file_id='https://generativelanguage.googleapis.com/v1beta/files/abc123',
provider_name='google',
)
assert uploaded_file_url.identifier == snapshot('d8d637')
def test_uploaded_file_format():
"""Test UploadedFile.format property for different media types."""
# Test with no media_type - defaults to 'application/octet-stream' which has no format
uploaded_file = UploadedFile(file_id='file-abc123', provider_name='anthropic')
assert uploaded_file.media_type == 'application/octet-stream'
with pytest.raises(ValueError, match='Unknown media type'):
uploaded_file.format
# Test with image media_type
uploaded_file = UploadedFile(file_id='file-abc123', provider_name='anthropic', media_type='image/png')
assert uploaded_file.format == 'png'
# Test with video media_type
uploaded_file = UploadedFile(file_id='file-abc123', provider_name='anthropic', media_type='video/mp4')
assert uploaded_file.format == 'mp4'
# Test with audio media_type
uploaded_file = UploadedFile(file_id='file-abc123', provider_name='anthropic', media_type='audio/wav')
assert uploaded_file.format == 'wav'
# Test with document media_type
uploaded_file = UploadedFile(file_id='file-abc123', provider_name='anthropic', media_type='application/pdf')
assert uploaded_file.format == 'pdf'
# Test with unknown media_type - should raise ValueError
uploaded_file = UploadedFile(file_id='file-abc123', provider_name='anthropic', media_type='application/custom')
with pytest.raises(ValueError, match='Unknown media type'):
uploaded_file.format
def test_uploaded_file_in_otel_message_parts():
"""Test that UploadedFile is handled correctly in otel message parts conversion.
Per OTel GenAI spec, UploadedFile maps to FilePart with type='file', modality, and file_id.
See: https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-input-messages.json
"""
# Test with file ID (OTel FilePart format) - no media_type defaults to 'application/octet-stream'
part = UserPromptPart(
content=['text before', UploadedFile(file_id='file-abc123', provider_name='anthropic'), 'text after']
)
settings = InstrumentationSettings(include_content=True)
otel_parts = part.otel_message_parts(settings)
assert otel_parts == snapshot(
[
{'type': 'text', 'content': 'text before'},
{'type': 'file', 'modality': 'document', 'file_id': 'file-abc123', 'mime_type': 'application/octet-stream'},
{'type': 'text', 'content': 'text after'},
]
)
# Test with URL file_id (still uses file_id field per spec) - no extension defaults to 'application/octet-stream'
part_url = UserPromptPart(
content=[
'analyze this',
UploadedFile(
file_id='https://generativelanguage.googleapis.com/v1beta/files/abc123',
provider_name='google',
),
]
)
otel_parts_url = part_url.otel_message_parts(settings)
assert otel_parts_url == snapshot(
[
{'type': 'text', 'content': 'analyze this'},
{
'type': 'file',
'modality': 'document',
'file_id': 'https://generativelanguage.googleapis.com/v1beta/files/abc123',
'mime_type': 'application/octet-stream',
},
]
)
# Test with S3 URL and media_type - should include modality and mime_type
part_s3 = UserPromptPart(
content=[
'process this',
UploadedFile(file_id='s3://my-bucket/my-file.pdf', provider_name='bedrock', media_type='application/pdf'),
]
)
otel_parts_s3 = part_s3.otel_message_parts(settings)
assert otel_parts_s3 == snapshot(
[
{'type': 'text', 'content': 'process this'},
{
'type': 'file',
'modality': 'document',
'file_id': 's3://my-bucket/my-file.pdf',
'mime_type': 'application/pdf',
},
]
)
# Test with image media_type - should have image modality
part_image = UserPromptPart(
content=[UploadedFile(file_id='img-123', provider_name='openai', media_type='image/png')]
)
otel_parts_image = part_image.otel_message_parts(settings)
assert otel_parts_image == snapshot(
[{'type': 'file', 'modality': 'image', 'file_id': 'img-123', 'mime_type': 'image/png'}]
)
# Test with audio media_type - should have audio modality
part_audio = UserPromptPart(
content=[UploadedFile(file_id='audio-123', provider_name='openai', media_type='audio/mp3')]
)
otel_parts_audio = part_audio.otel_message_parts(settings)
assert otel_parts_audio == snapshot(
[{'type': 'file', 'modality': 'audio', 'file_id': 'audio-123', 'mime_type': 'audio/mp3'}]
)
# Test with video media_type - should have video modality
part_video = UserPromptPart(
content=[UploadedFile(file_id='video-123', provider_name='openai', media_type='video/mp4')]
)
otel_parts_video = part_video.otel_message_parts(settings)
assert otel_parts_video == snapshot(
[{'type': 'file', 'modality': 'video', 'file_id': 'video-123', 'mime_type': 'video/mp4'}]
)
# Test without include_content (should have type, modality, and mime_type but not file_id)
settings_no_content = InstrumentationSettings(include_content=False)
otel_parts_no_content = part.otel_message_parts(settings_no_content)
assert otel_parts_no_content == snapshot(
[
{'type': 'text'},
{'type': 'file', 'modality': 'document', 'mime_type': 'application/octet-stream'},
{'type': 'text'},
]
)
def test_uploaded_file_serialization_roundtrip():
"""Verify that UploadedFile survives a ModelMessagesTypeAdapter serialization roundtrip.
UploadedFile uses `exclude=True` on private fields (`_media_type`, `_identifier`) and exposes
them via computed fields — this test ensures those computed values are preserved through
serialization and deserialization.
"""
messages: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(
content=[
'analyze this file',
UploadedFile(file_id='file-abc123', provider_name='anthropic', media_type='application/pdf'),
]
)
]
)
]
serialized = ModelMessagesTypeAdapter.dump_python(messages, mode='json')
deserialized = ModelMessagesTypeAdapter.validate_python(serialized)
assert deserialized == messages
def test_uploaded_file_custom_identifier_and_media_type_roundtrip():
"""Verify that custom `identifier` and `media_type` survive serialization roundtrip."""
messages: list[ModelMessage] = [
ModelRequest(
parts=[
UserPromptPart(
content=[
UploadedFile(
file_id='file-abc123',
provider_name='anthropic',
media_type='image/png',
identifier='my-id',
),
]
)
]
)
]
serialized = ModelMessagesTypeAdapter.dump_python(messages, mode='json')
deserialized = ModelMessagesTypeAdapter.validate_python(serialized)
part = message_part(deserialized, UserPromptPart)
uploaded = part.content[0]
assert isinstance(uploaded, UploadedFile)
assert uploaded.identifier == 'my-id'
assert uploaded.media_type == 'image/png'
assert deserialized == messages
def test_tool_return_content_with_url_field_not_coerced_to_image_url():
"""Test that dicts with 'url' keys are not incorrectly coerced to ImageUrl.
Regression test for: https://github.com/pydantic/pydantic-ai/issues/4190
Without a discriminator on MultiModalContent union, Pydantic would incorrectly
match any dict containing a 'url' key against ImageUrl (first union member),
causing data loss.
"""
serialized_history = r"""[
{
"parts": [{"content": "Hello", "timestamp": "2026-02-03T22:25:50Z", "part_kind": "user-prompt"}],
"kind": "request"
},
{
"parts": [{"tool_name": "my_tool", "args": "{}", "tool_call_id": "call_1", "part_kind": "tool-call"}],
"model_name": "test",
"timestamp": "2026-02-03T22:26:39Z",
"kind": "response"
},
{
"parts": [
{
"tool_name": "my_tool",
"content": {
"items": [{"name": "Example", "url": "/some/path/12345"}]
},
"tool_call_id": "call_1",
"timestamp": "2026-02-03T22:27:32Z",
"part_kind": "tool-return"
}
],
"kind": "request"
}
]
"""
# Deserialize - the dict with 'url' should remain as a dict, not become ImageUrl
deserialized = ModelMessagesTypeAdapter.validate_json(serialized_history)
tool_return_part = message_part(deserialized, ToolReturnPart, message_index=2)
# The content should be preserved as a dict, not coerced to ImageUrl
expected_content = {'items': [{'name': 'Example', 'url': '/some/path/12345'}]}
assert tool_return_part.content == expected_content
# Round-trip should work without errors
reserialized = ModelMessagesTypeAdapter.dump_json(deserialized)
reloaded = ModelMessagesTypeAdapter.validate_json(reserialized)
reloaded_tool_return = message_part(reloaded, ToolReturnPart, message_index=2)
assert reloaded_tool_return.content == expected_content
def test_tool_return_content_with_explicit_image_url():
"""Test that ImageUrl with explicit 'kind' discriminator is correctly deserialized."""
from pydantic_ai.messages import ToolReturnPart
serialized_history = r"""[
{
"parts": [{"content": "Hello", "timestamp": "2026-02-03T22:25:50Z", "part_kind": "user-prompt"}],
"kind": "request"
},
{
"parts": [
{
"tool_name": "image_tool",
"content": {
"url": "https://example.com/image.png",
"kind": "image-url"
},
"tool_call_id": "call_1",
"timestamp": "2026-02-03T22:27:32Z",
"part_kind": "tool-return"
}
],
"kind": "request"
}
]
"""
deserialized = ModelMessagesTypeAdapter.validate_json(serialized_history)
tool_return_part = message_part(deserialized, ToolReturnPart, message_index=1)
# Content with explicit kind: "image-url" should become ImageUrl
assert isinstance(tool_return_part.content, ImageUrl)
assert tool_return_part.content.url == 'https://example.com/image.png'
def test_tool_return_content_nested_multimodal():
"""Test that nested MultiModalContent types with explicit discriminators work."""
from pydantic_ai.messages import ToolReturnPart
serialized_history = r"""[
{
"parts": [
{
"tool_name": "mixed_tool",
"content": {
"images": [
{"url": "https://example.com/img1.jpg", "kind": "image-url"},
{"url": "https://example.com/img2.png", "kind": "image-url"}
],
"documents": [
{"url": "https://example.com/doc.pdf", "kind": "document-url"}
],
"regular_data": [
{"url": "/api/path", "id": 123, "name": "test"}
]
},
"tool_call_id": "call_1",
"timestamp": "2026-02-03T22:27:32Z",
"part_kind": "tool-return"
}
],
"kind": "request"
}
]
"""
deserialized = ModelMessagesTypeAdapter.validate_json(serialized_history)
tool_return_part = message_part(deserialized, ToolReturnPart)
# `ToolReturnPart`'s typed `ToolSearchReturnPart` subclass narrows `content` to a
# `TypedDict`; cast back to a plain dict so we can probe arbitrary keys here.
content = cast('dict[str, Any]', tool_return_part.content)
assert isinstance(content, dict)
# Items with kind: "image-url" should be ImageUrl
assert isinstance(content['images'][0], ImageUrl)
assert isinstance(content['images'][1], ImageUrl)
# Items with kind: "document-url" should be DocumentUrl
assert isinstance(content['documents'][0], DocumentUrl)
# Items without kind should remain as dicts
assert content['regular_data'] == [{'url': '/api/path', 'id': 123, 'name': 'test'}]
# Round-trip should preserve types
reserialized = ModelMessagesTypeAdapter.dump_json(deserialized)
reloaded = ModelMessagesTypeAdapter.validate_json(reserialized)
reloaded_tool_return = message_part(reloaded, ToolReturnPart)
reloaded_content = cast('dict[str, Any]', reloaded_tool_return.content)
assert isinstance(reloaded_content, dict)
assert isinstance(reloaded_content['images'][0], ImageUrl)
assert isinstance(reloaded_content['documents'][0], DocumentUrl)
assert reloaded_content['regular_data'] == [{'url': '/api/path', 'id': 123, 'name': 'test'}]
def test_multi_modal_content_types_matches_union():
"""Validate that MULTI_MODAL_CONTENT_TYPES matches the MultiModalContent union members,
and that is_multi_modal_content correctly narrows types."""
# Unwrap any `Annotated` wrappers (e.g. `BinaryContent` carries an `AfterValidator` that narrows
# image content to `BinaryImage`) so the comparison is against the underlying content types.
union_members = {
get_args(m)[0] if get_origin(m) is Annotated else m for m in get_args(get_args(MultiModalContent)[0])
}
assert set(MULTI_MODAL_CONTENT_TYPES) == union_members
# Positive cases: each multimodal type is recognized
assert is_multi_modal_content(ImageUrl(url='https://example.com/image.png'))
assert is_multi_modal_content(AudioUrl(url='https://example.com/audio.mp3'))
assert is_multi_modal_content(DocumentUrl(url='https://example.com/doc.pdf'))
assert is_multi_modal_content(VideoUrl(url='https://example.com/video.mp4'))
assert is_multi_modal_content(BinaryContent(data=b'\x89PNG', media_type='image/png'))
# Negative cases: non-multimodal types
assert not is_multi_modal_content('a string')
assert not is_multi_modal_content({'key': 'value'})
assert not is_multi_modal_content(42)
@pytest.mark.parametrize('mode', ['json', 'python'])
def test_binary_image_narrowed_wherever_multimodal_content_is_validated(mode: str):
"""An image `BinaryContent` narrows to `BinaryImage` on validation of any `MultiModalContent`
(here via `UserPromptPart`), not just `FilePart.content`; non-image `BinaryContent` is left as-is.
"""
image = BinaryContent(data=b'\x89PNG', media_type='image/png')
audio = BinaryContent(data=b'\x00\x01', media_type='audio/mpeg')
messages: list[ModelMessage] = [ModelRequest(parts=[UserPromptPart(content=[image, audio])])]
if mode == 'json':
loaded = ModelMessagesTypeAdapter.validate_json(ModelMessagesTypeAdapter.dump_json(messages))
else:
loaded = ModelMessagesTypeAdapter.validate_python(ModelMessagesTypeAdapter.dump_python(messages, mode='json'))
part = message_part(loaded, UserPromptPart)
assert isinstance(part.content, list)
reloaded_image, reloaded_audio = part.content
assert type(reloaded_image) is BinaryImage
assert reloaded_image.data == image.data and reloaded_image.media_type == image.media_type
# Non-image content is not narrowed.
assert type(reloaded_audio) is BinaryContent
def test_every_multimodal_type_rehydrates_as_tool_return_content():
"""Every `MultiModalContent` type, dumped as scalar `ToolReturnPart.content`, must rehydrate to
its own subclass through `ModelMessagesTypeAdapter` — not collapse to a plain dict.
Guards the `ToolReturnContent` discriminator's type-specific-field gate (`_MULTIMODAL_FIELDS`):
if a future `MultiModalContent` type serialized without a `url`/`media_type`/`file_id` key, the
gate would route its dumped dict to the `mapping` branch and silently stop rehydrating it. The
factory must cover exactly `MULTI_MODAL_CONTENT_TYPES`, so a new type forces a deliberate update.
`BinaryContent` uses a non-image media type so it isn't narrowed to `BinaryImage`.
"""
samples: dict[type, MultiModalContent] = {
ImageUrl: ImageUrl(url='https://example.com/a.png'),
AudioUrl: AudioUrl(url='https://example.com/a.mp3'),
VideoUrl: VideoUrl(url='https://example.com/a.mp4'),
DocumentUrl: DocumentUrl(url='https://example.com/a.pdf'),
BinaryContent: BinaryContent(data=b'x', media_type='application/pdf'),
UploadedFile: UploadedFile(file_id='f1', provider_name='openai', media_type='image/png'),
}
assert set(samples) == set(MULTI_MODAL_CONTENT_TYPES)
for cls, instance in samples.items():
messages: list[ModelMessage] = [
ModelRequest(parts=[ToolReturnPart(tool_name='t', content=instance, tool_call_id='c')])
]
reloaded = ModelMessagesTypeAdapter.validate_python(ModelMessagesTypeAdapter.dump_python(messages, mode='json'))
part = message_part(reloaded, ToolReturnPart)
assert type(part.content) is cls, (
f'{cls.__name__} did not rehydrate through the discriminator gate '
f'(got {type(part.content).__name__}) — a `_MULTIMODAL_FIELDS` mismatch would cause this'
)
def test_tool_return_part_binary_content_serialization():
png_data = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc```\x00\x00\x00\x04\x00\x01\xf6\x178\x00\x00\x00\x00IEND\xaeB`\x82'
binary_content = BinaryContent(png_data, media_type='image/png')
tool_return = ToolReturnPart(tool_name='test_tool', content=binary_content, tool_call_id='test_call_123')
assert tool_return.model_response_object() == snapshot({})
@pytest.mark.parametrize('case_id', ['scalar', 'list-with-binary', 'dict-with-nested-binary'])
def test_tool_return_part_binary_content_round_trip(case_id: str, tiny_audio: BinaryContent):
"""`ToolReturnPart.content` containing `BinaryContent` (scalar, in a list, or in a dict)
must round-trip via `ModelMessagesTypeAdapter` in both `validate_json` (the wire path)
and `validate_python` (the replay path used by UI adapters that already parsed JSON).
Without the explicit `Discriminator` on `ToolReturnContent`, smart-union resolution picks
`Mapping`/`Sequence`/`Any` over the discriminated `MultiModalContent` branch in
`validate_python`, leaving binary leaves as plain dicts.
Uses `tiny_audio` (non-image `BinaryContent`) to focus on rehydration, not the
`BinaryImage` narrowing applied by UI adapters.
"""
contents: dict[str, ToolReturnContent] = {
'scalar': tiny_audio,
'list-with-binary': ['hello', tiny_audio],
'dict-with-nested-binary': {'caption': 'see audio', 'attachment': tiny_audio},
}
content = contents[case_id]
messages: list[ModelMessage] = [
ModelRequest(parts=[ToolReturnPart(tool_name='t', content=content, tool_call_id='c')])
]
json_loaded = ModelMessagesTypeAdapter.validate_json(ModelMessagesTypeAdapter.dump_json(messages))
json_part = message_part(json_loaded, ToolReturnPart)
assert json_part.content == content
python_loaded = ModelMessagesTypeAdapter.validate_python(
ModelMessagesTypeAdapter.dump_python(messages, mode='json')
)
python_part = message_part(python_loaded, ToolReturnPart)
assert python_part.content == content
@pytest.mark.parametrize(
'content',
[
pytest.param({'kind': 'binary', 'label': 'foo'}, id='kind-binary-no-media-type'),
pytest.param({'kind': 'image-url', 'note': 'not a real url part'}, id='kind-url-no-media-type'),
],
)
def test_tool_return_dict_reusing_kind_without_type_field_stays_mapping(content: dict[str, str]):
"""A user dict that reuses one of our `kind` values but lacks a type-specific field
(`media_type`/`file_id`) is left as a plain mapping rather than forced through
`MultiModalContent` validation (which would raise a hard `ValidationError`).
The discriminator is wired into core `ToolReturnContent`, so this guards every
`ModelMessagesTypeAdapter` round trip, not just the UI adapters.
"""
messages: list[ModelMessage] = [
ModelRequest(parts=[ToolReturnPart(tool_name='t', content=content, tool_call_id='c')])
]
loaded = ModelMessagesTypeAdapter.validate_python(ModelMessagesTypeAdapter.dump_python(messages, mode='json'))
part = message_part(loaded, ToolReturnPart)
assert part.content == content
@pytest.mark.parametrize(
'content',
[
# Reserved `kind` + a type-specific field, but not a valid instance of that type:
pytest.param({'kind': 'binary', 'media_type': 'text/plain', 'text': 'hello'}, id='binary-without-data'),
pytest.param(
{'kind': 'uploaded-file', 'file_id': 'abc', 'status': 'ready'}, id='uploaded-file-without-provider'
),
pytest.param({'kind': 'image-url', 'media_type': 'image/png', 'note': 'x'}, id='image-url-without-url'),
],
)
@pytest.mark.parametrize('mode', ['json', 'python'])
def test_tool_return_dict_reusing_kind_with_type_field_stays_mapping(content: dict[str, str], mode: str):
"""A user dict that reuses a `kind` value AND carries a type field (`media_type`/`url`/`file_id`)
but isn't a valid instance of that type must stay a plain mapping, not raise.
The discriminator gates such a dict into the `multimodal` branch on the `kind`+field heuristic;
`_validate_multimodal_or_passthrough` falls back to the raw dict when `MultiModalContent` validation
fails, and `_serialize_multimodal_or_passthrough` dumps it without a spurious serializer warning —
together matching the pre-discriminator behavior where these fell through to the `Any` arm.
"""
messages: list[ModelMessage] = [
ModelRequest(parts=[ToolReturnPart(tool_name='t', content=content, tool_call_id='c')])
]
with warnings.catch_warnings():
warnings.simplefilter('error') # a `PydanticSerializationUnexpectedValue` warning would fail here
if mode == 'json':
loaded = ModelMessagesTypeAdapter.validate_json(ModelMessagesTypeAdapter.dump_json(messages))
else:
loaded = ModelMessagesTypeAdapter.validate_python(
ModelMessagesTypeAdapter.dump_python(messages, mode='json')
)
part = message_part(loaded, ToolReturnPart)
assert part.content == content
@pytest.mark.parametrize(
'kind',
[
pytest.param([1, 2], id='kind-list'),
pytest.param({'x': 'y'}, id='kind-dict'),
pytest.param(bytearray(b'binary'), id='kind-bytearray'),
],
)
@pytest.mark.parametrize('nested', [False, True], ids=['top-level', 'nested-in-sequence'])
def test_tool_return_dict_unhashable_kind_stays_mapping(kind: object, nested: bool):
"""A client dict whose `kind` is unhashable must not crash the discriminator with a `TypeError`.
The discriminator's `kind in _MULTIMODAL_KINDS` membership test raises `TypeError` on an unhashable
`kind` (`list`/`dict`/`bytearray`); the `isinstance(kind, str)` guard routes it to the `mapping`
branch instead, where it round-trips as a plain mapping — the same graceful handling of malformed
client input as the `_js_binary_to_bytes` hardening.
"""
inner: dict[str, Any] = {'kind': kind, 'media_type': 'image/png', 'data': 'YWJj'}
content: Any = [inner] if nested else inner
dumped = {
'parts': [{'tool_name': 't', 'content': content, 'tool_call_id': 'c', 'part_kind': 'tool-return'}],
'kind': 'request',
}
loaded = ModelMessagesTypeAdapter.validate_python([dumped])
part = message_part(loaded, ToolReturnPart)
assert part.content == content
def test_tool_return_part_list_structure_preserved():
single_dict = {'result': 'found'}
single_item_list = [{'result': 'found'}]
multi_item_list = [{'a': 1}, {'b': 2}]
tool_return_dict = ToolReturnPart(tool_name='test', content=single_dict, tool_call_id='tc1')
assert tool_return_dict.model_response_object() == snapshot({'result': 'found'})
assert tool_return_dict.model_response_str() == snapshot('{"result":"found"}')
tool_return_single_list = ToolReturnPart(tool_name='test', content=single_item_list, tool_call_id='tc2')
assert tool_return_single_list.model_response_object() == snapshot({'return_value': [{'result': 'found'}]})
assert tool_return_single_list.model_response_str() == snapshot('[{"result":"found"}]')
tool_return_multi_list = ToolReturnPart(tool_name='test', content=multi_item_list, tool_call_id='tc3')
assert tool_return_multi_list.model_response_object() == snapshot({'return_value': [{'a': 1}, {'b': 2}]})
assert tool_return_multi_list.model_response_str() == snapshot('[{"a":1},{"b":2}]')
def test_tool_return_part_content_items():
img = ImageUrl(url='https://example.com/img.png')
binary = BinaryContent(data=b'\x89PNG', media_type='image/png')
p_str = ToolReturnPart(tool_name='t', content='hello', tool_call_id='c1')
assert p_str.content_items() == snapshot(['hello'])
assert p_str.content_items(mode='raw') == snapshot(['hello'])
assert p_str.content_items(mode='str') == snapshot(['hello'])
assert p_str.content_items(mode='jsonable') == snapshot(['hello'])
p_dict = ToolReturnPart(tool_name='t', content={'key': 'val'}, tool_call_id='c2')
assert p_dict.content_items() == snapshot([{'key': 'val'}])
assert p_dict.content_items(mode='str') == snapshot(['{"key":"val"}'])
assert p_dict.content_items(mode='jsonable') == snapshot([{'key': 'val'}])
p_int = ToolReturnPart(tool_name='t', content=42, tool_call_id='c3')
assert p_int.content_items() == snapshot([42])
assert p_int.content_items(mode='str') == snapshot(['42'])
assert p_int.content_items(mode='jsonable') == snapshot([42])
p_file = ToolReturnPart(tool_name='t', content=img, tool_call_id='c4')
assert p_file.content_items(mode='str') == snapshot([ImageUrl(url='https://example.com/img.png')])
assert p_file.content_items(mode='jsonable') == snapshot([ImageUrl(url='https://example.com/img.png')])
p_mixed = ToolReturnPart(tool_name='t', content=['text result', img, binary], tool_call_id='c5')
assert p_mixed.content_items() == snapshot(
[
'text result',
ImageUrl(url='https://example.com/img.png'),
BinaryContent(data=b'\x89PNG', media_type='image/png'),
]
)
assert p_mixed.content_items(mode='str') == snapshot(
[
'text result',
ImageUrl(url='https://example.com/img.png'),
BinaryContent(data=b'\x89PNG', media_type='image/png'),
]
)
assert p_mixed.content_items(mode='jsonable') == snapshot(
[
'text result',
ImageUrl(url='https://example.com/img.png'),
BinaryContent(data=b'\x89PNG', media_type='image/png'),
]
)
p_list = ToolReturnPart(tool_name='t', content=[{'a': 1}, {'b': 2}], tool_call_id='c6')
assert p_list.content_items(mode='str') == snapshot(['{"a":1}', '{"b":2}'])
assert p_list.content_items(mode='jsonable') == snapshot([{'a': 1}, {'b': 2}])
def test_tool_return_part_files_property():
img = ImageUrl(url='https://example.com/img.png')
audio = AudioUrl(url='https://example.com/audio.mp3')
binary = BinaryContent(data=b'\x89PNG', media_type='image/png')
p_str = ToolReturnPart(tool_name='t', content='hello', tool_call_id='c1')
assert p_str.files == snapshot([])
p_dict = ToolReturnPart(tool_name='t', content={'key': 'val'}, tool_call_id='c2')
assert p_dict.files == snapshot([])
p_file = ToolReturnPart(tool_name='t', content=img, tool_call_id='c3')
assert p_file.files == snapshot([ImageUrl(url='https://example.com/img.png')])
p_mixed = ToolReturnPart(tool_name='t', content=['text', img, {'data': 1}, audio, binary], tool_call_id='c4')
assert p_mixed.files == snapshot(
[
ImageUrl(url='https://example.com/img.png'),
AudioUrl(url='https://example.com/audio.mp3'),
BinaryContent(data=b'\x89PNG', media_type='image/png'),
]
)
p_no_files = ToolReturnPart(tool_name='t', content=['a', 'b'], tool_call_id='c5')
assert p_no_files.files == snapshot([])
def test_tool_return_part_response_methods_with_files():
img = ImageUrl(url='https://example.com/img.png')
p_text_file = ToolReturnPart(tool_name='t', content=['hello', img], tool_call_id='c1')
assert p_text_file.model_response_str() == snapshot('hello')
assert p_text_file.model_response_object() == snapshot({'return_value': 'hello'})
p_dict_file = ToolReturnPart(tool_name='t', content=[{'key': 'val'}, img], tool_call_id='c2')
assert p_dict_file.model_response_str() == snapshot('{"key":"val"}')
assert p_dict_file.model_response_object() == snapshot({'key': 'val'})
p_single_list = ToolReturnPart(tool_name='t', content=['hello'], tool_call_id='c3')
assert p_single_list.model_response_str() == snapshot('["hello"]')
assert p_single_list.model_response_object() == snapshot({'return_value': ['hello']})
p_file_only = ToolReturnPart(tool_name='t', content=img, tool_call_id='c4')
assert p_file_only.model_response_str() == snapshot('')
assert p_file_only.model_response_object() == snapshot({})
p_multi = ToolReturnPart(tool_name='t', content=['a', 'b', img], tool_call_id='c5')
assert p_multi.model_response_str() == snapshot('["a","b"]')
assert p_multi.model_response_object() == snapshot({'return_value': ['a', 'b']})
def test_tool_return_part_model_response_str_and_user_content():
img = ImageUrl(url='https://example.com/img.png')
# Scalar string, no files → fast path returns model_response_str
p_no_files = ToolReturnPart(tool_name='t', content='hello', tool_call_id='c1')
text, user_content = p_no_files.model_response_str_and_user_content()
assert text == snapshot('hello')
assert user_content == snapshot([])
# Single-element list, no files → list structure preserved
p_single_list = ToolReturnPart(tool_name='t', content=['hello'], tool_call_id='c1b')
text, user_content = p_single_list.model_response_str_and_user_content()
assert text == snapshot('["hello"]')
assert user_content == snapshot([])
# Single text + file → scalar text, not JSON array
p_text_file = ToolReturnPart(tool_name='t', content=['hello', img], tool_call_id='c2')
text, user_content = p_text_file.model_response_str_and_user_content()
assert text == snapshot('["hello","See file d5a901."]')
assert user_content == snapshot(['This is file d5a901:', ImageUrl(url='https://example.com/img.png')])
# Multiple text items + file → JSON array preserves list structure
p_multi = ToolReturnPart(tool_name='t', content=['text1', img, 'text2'], tool_call_id='c3')
text, user_content = p_multi.model_response_str_and_user_content()
assert text == snapshot('["text1","See file d5a901.","text2"]')
assert user_content == snapshot(['This is file d5a901:', ImageUrl(url='https://example.com/img.png')])
# File-only content
p_file_only = ToolReturnPart(tool_name='t', content=img, tool_call_id='c4')
text, user_content = p_file_only.model_response_str_and_user_content()
assert text == snapshot('See file d5a901.')
assert user_content == snapshot(['This is file d5a901:', ImageUrl(url='https://example.com/img.png')])
def test_args_as_dict_valid_json():
"""args_as_dict should return parsed dict for valid JSON args."""
part = ToolCallPart(tool_name='test_tool', args='{"key": "value"}')
assert part.args_as_dict() == {'key': 'value'}
def test_args_as_dict_dict_args():
"""args_as_dict should return the dict directly when args is already a dict."""
part = ToolCallPart(tool_name='test_tool', args={'key': 'value'})
assert part.args_as_dict() == {'key': 'value'}
def test_args_as_dict_malformed_json_returns_invalid_json_wrapper():
"""args_as_dict should return INVALID_JSON wrapper for malformed JSON by default."""
malformed = '{"query": "bad", "ids":[4556]\n