9201ef759e
CI / lint (push) Waiting to run
CI / mypy (push) Waiting to run
CI / docs (push) Waiting to run
CI / test on 3.10 (standard) (push) Waiting to run
CI / test on 3.11 (standard) (push) Waiting to run
CI / test on 3.12 (standard) (push) Waiting to run
CI / test on 3.10 (all-extras) (push) Waiting to run
CI / test on 3.11 (all-extras) (push) Waiting to run
CI / test on 3.12 (all-extras) (push) Waiting to run
CI / test on 3.13 (all-extras) (push) Waiting to run
CI / test on 3.14 (pydantic-evals) (push) Waiting to run
Harness Compat / harness compat (push) Waiting to run
CI / test on 3.13 (standard) (push) Waiting to run
CI / test on 3.14 (standard) (push) Waiting to run
CI / test on 3.14 (all-extras) (push) Waiting to run
CI / test on 3.10 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.11 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.12 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.13 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.14 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.10 (pydantic-evals) (push) Waiting to run
CI / test on 3.11 (pydantic-evals) (push) Waiting to run
CI / test on 3.12 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (pydantic-evals) (push) Waiting to run
CI / test on 3.10 (lowest-versions) (push) Waiting to run
CI / test on 3.11 (lowest-versions) (push) Waiting to run
CI / test on 3.12 (lowest-versions) (push) Waiting to run
CI / test on 3.13 (lowest-versions) (push) Waiting to run
CI / test on 3.14 (lowest-versions) (push) Waiting to run
CI / test examples on 3.11 (push) Waiting to run
CI / test examples on 3.12 (push) Waiting to run
CI / test examples on 3.13 (push) Waiting to run
CI / test examples on 3.14 (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / check (push) Blocked by required conditions
CI / deploy-docs (push) Blocked by required conditions
CI / deploy-docs-preview (push) Blocked by required conditions
CI / build release artifacts (push) Blocked by required conditions
CI / publish to PyPI (push) Blocked by required conditions
CI / Send tweet (push) Blocked by required conditions
1995 lines
82 KiB
Python
1995 lines
82 KiB
Python
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('<think>about trains</think>', encoding='utf-8')
|
|
binary_content = BinaryContent.from_path(test_xml_file)
|
|
assert binary_content == snapshot(BinaryContent(data=b'<think>about trains</think>', 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]</parameter>\n<parameter name="limit": 8}'
|
|
part = ToolCallPart(tool_name='test_tool', args=malformed)
|
|
result = part.args_as_dict()
|
|
assert result == {INVALID_JSON_KEY: malformed}
|
|
|
|
|
|
def test_args_as_dict_non_dict_json_returns_invalid_json_wrapper():
|
|
"""args_as_dict should return INVALID_JSON wrapper for valid JSON that's not a dict."""
|
|
json_list = '[1, 2, 3]'
|
|
part = ToolCallPart(tool_name='test_tool', args=json_list)
|
|
assert part.args_as_dict() == {INVALID_JSON_KEY: json_list}
|
|
|
|
|
|
def test_args_as_dict_empty_args():
|
|
"""args_as_dict should return {} when args is None/empty."""
|
|
part = ToolCallPart(tool_name='test_tool', args=None)
|
|
assert part.args_as_dict() == {}
|
|
|
|
|
|
def test_args_as_dict_raise_if_invalid_malformed_json():
|
|
"""args_as_dict(raise_if_invalid=True) should raise ValueError on malformed JSON."""
|
|
malformed = '{"query": "bad", "ids":[4556]</parameter>\n<parameter name="limit": 8}'
|
|
part = ToolCallPart(tool_name='test_tool', args=malformed)
|
|
with pytest.raises(ValueError):
|
|
part.args_as_dict(raise_if_invalid=True)
|
|
|
|
|
|
def test_args_as_dict_raise_if_invalid_non_dict_json():
|
|
"""args_as_dict(raise_if_invalid=True) should raise AssertionError on non-dict JSON."""
|
|
part = ToolCallPart(tool_name='test_tool', args='[1, 2, 3]')
|
|
with pytest.raises(AssertionError):
|
|
part.args_as_dict(raise_if_invalid=True)
|
|
|
|
|
|
def test_user_prompt_part_with_text_content():
|
|
part = UserPromptPart(
|
|
content=[
|
|
'Hi there',
|
|
TextContent(content='This is text content', metadata={'key': 'value'}),
|
|
]
|
|
)
|
|
assert part.content[0] == 'Hi there'
|
|
assert part.content[1].metadata == snapshot({'key': 'value'}) # type: ignore[reportUnknownMemberType]
|
|
|
|
|
|
class TestInstructionParts:
|
|
def test_join_helper(self):
|
|
"""InstructionPart.join produces the correct joined string."""
|
|
parts = [
|
|
InstructionPart(content='First'),
|
|
InstructionPart(content='Second'),
|
|
]
|
|
assert InstructionPart.join(parts) == 'First\n\nSecond'
|
|
assert InstructionPart.join([]) is None
|
|
|
|
def test_join_strips_whitespace(self):
|
|
"""InstructionPart.join strips leading/trailing whitespace."""
|
|
parts = [InstructionPart(content=' Hello ')]
|
|
assert InstructionPart.join(parts) == 'Hello'
|
|
|
|
def test_model_request_instructions_is_plain_string(self):
|
|
"""ModelRequest.instructions is a plain str | None field."""
|
|
request = ModelRequest(parts=[], instructions='Hello world')
|
|
assert request.instructions == 'Hello world'
|
|
|
|
def test_model_request_instructions_default_none(self):
|
|
request = ModelRequest(parts=[])
|
|
assert request.instructions is None
|
|
|
|
def test_serialization_round_trip(self):
|
|
"""Instructions string survives serialization and deserialization."""
|
|
original = ModelRequest(parts=[UserPromptPart('test')], instructions='static part\n\ndynamic part')
|
|
|
|
serialized = ModelMessagesTypeAdapter.dump_json([original])
|
|
deserialized = ModelMessagesTypeAdapter.validate_json(serialized)
|
|
|
|
msg = message(deserialized, ModelRequest)
|
|
assert msg.instructions == 'static part\n\ndynamic part'
|
|
|
|
def test_repr(self):
|
|
"""InstructionPart repr omits default values."""
|
|
part = InstructionPart(content='hello')
|
|
assert repr(part) == "InstructionPart(content='hello')"
|
|
dynamic_part = InstructionPart(content='world', dynamic=True)
|
|
assert repr(dynamic_part) == "InstructionPart(content='world', dynamic=True)"
|
|
|
|
|
|
def test_retry_prompt_strips_input_from_top_level_errors():
|
|
"""Top-level validation errors should not include `input` in model_response() since it duplicates the entire generated output."""
|
|
part = RetryPromptPart(
|
|
content=[
|
|
{'type': 'missing', 'loc': ('required_field',), 'msg': 'Field required', 'input': {'wrong_field': 'value'}},
|
|
],
|
|
)
|
|
response = part.model_response()
|
|
assert '"input"' not in response
|
|
assert '"required_field"' in response
|
|
|
|
|
|
def test_retry_prompt_keeps_input_for_nested_errors():
|
|
"""Nested validation errors should keep `input` in model_response() to help the model locate the invalid part."""
|
|
part = RetryPromptPart(
|
|
content=[
|
|
{'type': 'missing', 'loc': ('items', 0, 'sub_field'), 'msg': 'Field required', 'input': {'other': 'val'}},
|
|
],
|
|
)
|
|
response = part.model_response()
|
|
assert '"input"' in response
|
|
assert '"sub_field"' in response
|
|
|
|
|
|
def test_retry_prompt_mixed_top_level_and_nested_errors():
|
|
"""When both top-level and nested errors exist, only top-level input should be stripped."""
|
|
part = RetryPromptPart(
|
|
content=[
|
|
{'type': 'missing', 'loc': ('root_field',), 'msg': 'Field required', 'input': {'root_key': 'root_val'}},
|
|
{
|
|
'type': 'missing',
|
|
'loc': ('items', 0, 'nested_field'),
|
|
'msg': 'Field required',
|
|
'input': {'nested_key': 'nested_val'},
|
|
},
|
|
],
|
|
)
|
|
response = part.model_response()
|
|
# Nested error's input should be present
|
|
assert '"nested_key"' in response
|
|
# But root-level input should not
|
|
assert '"root_key"' not in response
|
|
|
|
|
|
def test_retry_prompt_strips_input_from_top_level_type_errors():
|
|
"""Top-level type/value errors also have input stripped, even though it's a small scalar value."""
|
|
part = RetryPromptPart(
|
|
content=[
|
|
{
|
|
'type': 'int_parsing',
|
|
'loc': ('age',),
|
|
'msg': 'Input should be a valid integer, unable to parse string as an integer',
|
|
'input': 'not_a_number',
|
|
},
|
|
],
|
|
)
|
|
response = part.model_response()
|
|
assert '"input"' not in response
|
|
assert '"age"' in response
|
|
|
|
|
|
def test_retry_prompt_tool_call_keeps_input_at_top_level():
|
|
"""Tool-call retries (`tool_name` set) must preserve `input` so the model sees what args it sent."""
|
|
part = RetryPromptPart(
|
|
tool_name='evaluate_content',
|
|
content=[
|
|
{'type': 'missing', 'loc': ('content',), 'msg': 'Field required', 'input': {}},
|
|
],
|
|
)
|
|
response = part.model_response()
|
|
assert '"input": {}' in response
|
|
assert '"content"' in response
|
|
|
|
|
|
def test_retry_prompt_tool_call_keeps_input_for_nested_errors():
|
|
"""Tool-call retries preserve `input` for nested errors too, matching the existing NativeOutput nested behavior."""
|
|
part = RetryPromptPart(
|
|
tool_name='evaluate_content',
|
|
content=[
|
|
{
|
|
'type': 'string_type',
|
|
'loc': ('items', 0, 'name'),
|
|
'msg': 'Input should be a valid string',
|
|
'input': 42,
|
|
},
|
|
],
|
|
)
|
|
response = part.model_response()
|
|
assert '"input": 42' in response
|
|
assert '"name"' in response
|
|
|
|
|
|
def test_narrow_type_leaves_claim_free_part_unchanged_on_invalid_data():
|
|
"""Best-effort: a kwarg `tool_kind` claim whose data doesn't validate against the typed
|
|
subclass leaves the (claim-free) part untouched instead of raising.
|
|
|
|
Not reachable as a unit through one public flow: each part class's lenient branch sits
|
|
behind a different producer (dict-args providers for calls, UI adapters for returns),
|
|
so the four classes are pinned directly here.
|
|
"""
|
|
call = ToolCallPart(tool_name='load_capability', args={'name': 'oops'})
|
|
assert ToolCallPart.narrow_type(call, tool_kind='capability-load') is call
|
|
|
|
tool_return = ToolReturnPart(tool_name='load_capability', tool_call_id='c1', content='error text')
|
|
assert ToolReturnPart.narrow_type(tool_return, tool_kind='capability-load') is tool_return
|
|
|
|
native_call = NativeToolCallPart(tool_name='tool_search', args={'bad': 1})
|
|
assert NativeToolCallPart.narrow_type(native_call, tool_kind='tool-search') is native_call
|
|
|
|
native_return = NativeToolReturnPart(tool_name='tool_search', tool_call_id='c2', content='oops')
|
|
assert NativeToolReturnPart.narrow_type(native_return, tool_kind='tool-search') is native_return
|
|
|
|
|
|
def test_narrow_type_strips_unsubstantiated_tool_kind_set_on_part():
|
|
"""A `tool_kind` set directly on a part whose data doesn't validate against the typed subclass
|
|
is stripped (rather than left on a base part), across all four part classes.
|
|
|
|
Counterpart to the kwarg case above: there the claim is never on the part, here it is, so the
|
|
narrower must actively clear it.
|
|
"""
|
|
call = ToolCallPart(tool_name='load_capability', args={'name': 'oops'}, tool_kind='capability-load')
|
|
assert ToolCallPart.narrow_type(call) == replace(call, tool_kind=None)
|
|
|
|
tool_return = ToolReturnPart(
|
|
tool_name='load_capability', tool_call_id='c1', content='not-a-dict', tool_kind='capability-load'
|
|
)
|
|
assert ToolReturnPart.narrow_type(tool_return) == replace(tool_return, tool_kind=None)
|
|
|
|
native_call = NativeToolCallPart(tool_name='tool_search', args={'bad': 1}, tool_kind='tool-search')
|
|
assert NativeToolCallPart.narrow_type(native_call) == replace(native_call, tool_kind=None)
|
|
|
|
native_return = NativeToolReturnPart(
|
|
tool_name='tool_search', tool_call_id='c2', content='oops', tool_kind='tool-search'
|
|
)
|
|
assert NativeToolReturnPart.narrow_type(native_return) == replace(native_return, tool_kind=None)
|
|
|
|
|
|
def test_structured_content_returns_structured_json_or_none():
|
|
"""`structured_content` parses a JSON-string `content` into structured data (dict/list), returns
|
|
already-structured content as-is, and yields `None` for anything that isn't structured JSON."""
|
|
assert ToolReturnPart(tool_name='t', tool_call_id='c1', content='{"a": 1}').structured_content() == {'a': 1}
|
|
assert ToolReturnPart(tool_name='t', tool_call_id='c2', content={'a': 1}).structured_content() == {'a': 1}
|
|
assert ToolReturnPart(tool_name='t', tool_call_id='c3', content='[1, 2]').structured_content() == [1, 2]
|
|
# A non-JSON string, a JSON scalar, and a bare scalar all lack structured JSON data.
|
|
assert ToolReturnPart(tool_name='t', tool_call_id='c4', content='not json').structured_content() is None
|
|
assert ToolReturnPart(tool_name='t', tool_call_id='c5', content='"just a string"').structured_content() is None
|
|
assert ToolReturnPart(tool_name='t', tool_call_id='c6', content=42).structured_content() is None
|
|
|
|
|
|
def test_narrow_type_upgrades_json_string_content():
|
|
"""A typed return whose content arrives as a JSON string (as UI adapters transmit it) is parsed
|
|
and promoted to its typed subclass with structured content, not left as a base part."""
|
|
tool_return = ToolReturnPart(
|
|
tool_name='load_capability',
|
|
tool_call_id='c1',
|
|
content='{"instructions": "hi"}',
|
|
tool_kind='capability-load',
|
|
)
|
|
narrowed = ToolReturnPart.narrow_type(tool_return)
|
|
assert type(narrowed) is LoadCapabilityReturnPart
|
|
assert narrowed.content == {'instructions': 'hi'}
|
|
|
|
|
|
def test_stripped_tool_kind_part_survives_roundtrip():
|
|
"""A base part that kept an unvalidatable `tool_kind` would be routed back to the typed subclass
|
|
by the discriminator and fail validation on reload; stripping it preserves the round-trip."""
|
|
invalid = ToolReturnPart(
|
|
tool_name='load_capability', tool_call_id='c1', content='not-a-dict', tool_kind='capability-load'
|
|
)
|
|
messages: list[ModelMessage] = [ModelRequest(parts=[ToolReturnPart.narrow_type(invalid)])]
|
|
reloaded = ModelMessagesTypeAdapter.validate_python(ModelMessagesTypeAdapter.dump_python(messages))
|
|
assert type(reloaded[0].parts[0]) is ToolReturnPart
|
|
|
|
|
|
def test_narrow_message_parts_promotes_valid_claims_and_leaves_plain_parts():
|
|
"""`narrow_message_parts` promotes shape-valid claims to their typed subclass and leaves parts
|
|
without a `tool_kind` untouched (same object), so callers can hand it a whole history."""
|
|
messages: list[ModelMessage] = [
|
|
ModelResponse(
|
|
parts=[
|
|
ToolCallPart(
|
|
tool_name='load_capability', tool_call_id='c1', args={'id': 'foo'}, tool_kind='capability-load'
|
|
),
|
|
TextPart(content='hello'),
|
|
]
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
ToolReturnPart(
|
|
tool_name='load_capability',
|
|
tool_call_id='c1',
|
|
content={'instructions': 'hi'},
|
|
tool_kind='capability-load',
|
|
)
|
|
]
|
|
),
|
|
]
|
|
narrowed = narrow_message_parts(messages)
|
|
assert type(narrowed[0].parts[0]) is LoadCapabilityCallPart
|
|
assert narrowed[0].parts[1] is messages[0].parts[1]
|
|
assert type(narrowed[1].parts[0]) is LoadCapabilityReturnPart
|