Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c642ac7
review multi modal input handling
dsfaccini Dec 10, 2025
754c0f0
enable url media for openai responses
dsfaccini Dec 10, 2025
2f5bcf6
Update documentation for BinaryContent.base64 usage
dsfaccini Dec 10, 2025
5d99ed2
Merge branch 'main' into review-multimodal
dsfaccini Dec 10, 2025
ff5d79a
Merge branch 'main' into review-multimodal
dsfaccini Dec 10, 2025
887a79f
Merge branch 'main' into review-multimodal
dsfaccini Dec 10, 2025
6bba553
Address review comments: force_download, type rename, refactoring
dsfaccini Dec 11, 2025
7389467
upstream cerebras merge stuff
dsfaccini Dec 11, 2025
8ad810d
AsyncClient
dsfaccini Dec 11, 2025
da9ec78
base64 replacements
dsfaccini Dec 11, 2025
9a55ce2
add force download support for mistral
dsfaccini Dec 11, 2025
703b772
address review comments
dsfaccini Dec 13, 2025
35d8745
update docs
dsfaccini Dec 13, 2025
d9e8a01
allow explicit download disallowing
dsfaccini Dec 14, 2025
0bcd2e8
Merge remote-tracking branch 'origin/main' into review-multimodal
dsfaccini Dec 14, 2025
8a98f77
fix linting issue and update doc
dsfaccini Dec 14, 2025
45d35e5
fix tests
dsfaccini Dec 14, 2025
4a6234d
undo tri-optional force download and remove experiments folder
dsfaccini Dec 16, 2025
13c5284
include suggestion
dsfaccini Dec 16, 2025
91b1f79
include suggestion
dsfaccini Dec 16, 2025
0688875
Merge remote-tracking branch 'upstream/main' into review-multimodal
dsfaccini Dec 16, 2025
eab1e14
requested changes
dsfaccini Dec 16, 2025
6e0b72d
fix tests
dsfaccini Dec 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions pydantic_ai_slim/pydantic_ai/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,11 @@ class BinaryContent:
"""Binary content, e.g. an audio or image file."""

data: bytes
"""The binary data."""
"""Arbitrary binary data.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Arbitrary" may be a little too strong, as it is specifically a file matching the type in media_type 😄

I think if we make this The binary file data, we can remove the specific mention of base64 that's added in the next lines

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to keep one as it reduces the likelihood of users using it incorrectly


Store actual bytes here, not base64.
Use `.base64` to get the base64-encoded string.
"""

_: KW_ONLY

Expand Down Expand Up @@ -574,7 +578,12 @@ def identifier(self) -> str:
@property
def data_uri(self) -> str:
"""Convert the `BinaryContent` to a data URI."""
return f'data:{self.media_type};base64,{base64.b64encode(self.data).decode()}'
return f'data:{self.media_type};base64,{self.base64}'

@property
def base64(self) -> str:
"""Return the binary data as a base64-encoded string."""
return base64.b64encode(self.data).decode()

@property
def is_audio(self) -> bool:
Expand Down
51 changes: 37 additions & 14 deletions pydantic_ai_slim/pydantic_ai/models/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,33 @@ def _add_cache_control_to_last_param(
# Add cache_control to the last param
last_param['cache_control'] = self._build_cache_control(ttl)

@staticmethod
def _map_binary_content(item: BinaryContent) -> BetaContentBlockParam:
# Anthropic SDK accepts file-like objects (IO[bytes]) and handles base64 encoding internally
if item.is_image:
return BetaImageBlockParam(
source={'data': io.BytesIO(item.data), 'media_type': item.media_type, 'type': 'base64'}, # type: ignore
type='image',
)
elif item.media_type == 'application/pdf':
return BetaBase64PDFBlockParam(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now an alias for BetaRequestDocumentBlockParam, so let's use the newer name

source=BetaBase64PDFSourceParam(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm looking at what other sources this supports, and there's also BetaFileDocumentSourceParam, which takes a file_id for the file upload API.

We're adding support for uploaded files in #2611, but that PR has been stale for a bit so may be interesting for you to pick up.

data=io.BytesIO(item.data),
media_type='application/pdf',
type='base64',
),
type='document',
)
elif item.media_type == 'text/plain':
return BetaBase64PDFBlockParam(
source=BetaPlainTextSourceParam(
data=item.data.decode('utf-8'), media_type=item.media_type, type='text'
),
type='document',
)
else:
raise RuntimeError(f'Unsupported binary content media type for Anthropic: {item.media_type}')

@staticmethod
async def _map_user_prompt(
part: UserPromptPart,
Expand All @@ -1049,24 +1076,20 @@ async def _map_user_prompt(
elif isinstance(item, CachePoint):
yield item
elif isinstance(item, BinaryContent):
if item.is_image:
yield AnthropicModel._map_binary_content(item)
elif isinstance(item, ImageUrl):
if item.force_download:
downloaded = await download_item(item, data_format='bytes')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also respect force_download for DocumentUrl + item.media_type == 'application/pdf' further down, right?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to use _map_binary_content there, if we make it more generic so it can take the result of download_item? (Or take a FileUrl | BinaryContent and if it gets a FileUrl, do the download first)

yield BetaImageBlockParam(
source={'data': io.BytesIO(item.data), 'media_type': item.media_type, 'type': 'base64'}, # type: ignore
source={
'data': io.BytesIO(downloaded['data']),
'media_type': item.media_type,
'type': 'base64',
}, # type: ignore
type='image',
)
elif item.media_type == 'application/pdf':
yield BetaBase64PDFBlockParam(
source=BetaBase64PDFSourceParam(
data=io.BytesIO(item.data),
media_type='application/pdf',
type='base64',
),
type='document',
)
else:
raise RuntimeError('Only images and PDFs are supported for binary content')
elif isinstance(item, ImageUrl):
yield BetaImageBlockParam(source={'type': 'url', 'url': item.url}, type='image')
yield BetaImageBlockParam(source={'type': 'url', 'url': item.url}, type='image')
elif isinstance(item, DocumentUrl):
if item.media_type == 'application/pdf':
yield BetaBase64PDFBlockParam(source={'url': item.url, 'type': 'url'}, type='document')
Expand Down
31 changes: 15 additions & 16 deletions pydantic_ai_slim/pydantic_ai/models/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -1891,24 +1891,23 @@ async def _map_user_prompt(part: UserPromptPart) -> responses.EasyInputMessagePa
detail=detail,
)
)
elif isinstance(item, AudioUrl): # pragma: no cover
downloaded_item = await download_item(item, data_format='base64_uri', type_format='extension')
content.append(
responses.ResponseInputFileParam(
type='input_file',
file_data=downloaded_item['data'],
filename=f'filename.{downloaded_item["data_type"]}',
elif isinstance(item, AudioUrl | DocumentUrl):
if item.force_download:
downloaded_item = await download_item(item, data_format='base64_uri', type_format='extension')
content.append(
responses.ResponseInputFileParam(
type='input_file',
file_data=downloaded_item['data'],
filename=f'filename.{downloaded_item["data_type"]}',
)
)
)
elif isinstance(item, DocumentUrl):
downloaded_item = await download_item(item, data_format='base64_uri', type_format='extension')
content.append(
responses.ResponseInputFileParam(
type='input_file',
file_data=downloaded_item['data'],
filename=f'filename.{downloaded_item["data_type"]}',
else:
content.append(
responses.ResponseInputFileParam(
type='input_file',
file_url=item.url,
)
)
)
elif isinstance(item, VideoUrl): # pragma: no cover
raise NotImplementedError('VideoUrl is not supported for OpenAI.')
elif isinstance(item, CachePoint):
Expand Down
Loading
Loading