From 02ad24b07ff1bd0632b5d630dbe8be7691b02cb7 Mon Sep 17 00:00:00 2001 From: namkyu Date: Thu, 21 Aug 2025 21:42:28 +0900 Subject: [PATCH 01/15] add identifier field support to FileUrl and subclasses --- pydantic_ai_slim/pydantic_ai/_agent_graph.py | 2 +- pydantic_ai_slim/pydantic_ai/messages.py | 48 +++++++++++-- tests/test_agent.py | 72 +++++++++++++++++++- 3 files changed, 115 insertions(+), 7 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/_agent_graph.py b/pydantic_ai_slim/pydantic_ai/_agent_graph.py index 1e8beaec87..ddb2490e4b 100644 --- a/pydantic_ai_slim/pydantic_ai/_agent_graph.py +++ b/pydantic_ai_slim/pydantic_ai/_agent_graph.py @@ -769,7 +769,7 @@ def process_content(content: Any) -> Any: if isinstance(content, _messages.BinaryContent): identifier = content.identifier or multi_modal_content_identifier(content.data) else: - identifier = multi_modal_content_identifier(content.url) + identifier = content.identifier or multi_modal_content_identifier(content.url) user_parts.append( _messages.UserPromptPart( diff --git a/pydantic_ai_slim/pydantic_ai/messages.py b/pydantic_ai_slim/pydantic_ai/messages.py index 28447187ef..b22e4c041c 100644 --- a/pydantic_ai_slim/pydantic_ai/messages.py +++ b/pydantic_ai_slim/pydantic_ai/messages.py @@ -99,6 +99,14 @@ class FileUrl(ABC): * If False, the URL is sent directly to the model and no download is performed. """ + identifier: str | None = None + """Identifier for the file URL, such as a unique ID. + + This identifier can be provided to the model in a message to allow it to refer to this file in a tool call argument, and the tool can look up the file in question by iterating over the message history and finding the matching `FileUrl`. + + This identifier is only automatically passed to the model when the `FileUrl` is returned by a tool. If you're passing the `FileUrl` as a user message, it's up to you to include a separate text part with the identifier, e.g. "This is file :" preceding the `FileUrl`. + """ + vendor_metadata: dict[str, Any] | None = None """Vendor-specific metadata for the file. @@ -112,12 +120,14 @@ def __init__( self, url: str, force_download: bool = False, + identifier: str | None = None, vendor_metadata: dict[str, Any] | None = None, media_type: str | None = None, ) -> None: self.url = url - self.vendor_metadata = vendor_metadata self.force_download = force_download + self.identifier = identifier + self.vendor_metadata = vendor_metadata self._media_type = media_type @property @@ -153,11 +163,18 @@ def __init__( self, url: str, force_download: bool = False, + identifier: str | None = None, vendor_metadata: dict[str, Any] | None = None, media_type: str | None = None, kind: Literal['video-url'] = 'video-url', ) -> None: - super().__init__(url=url, force_download=force_download, vendor_metadata=vendor_metadata, media_type=media_type) + super().__init__( + url=url, + force_download=force_download, + identifier=identifier, + vendor_metadata=vendor_metadata, + media_type=media_type, + ) self.kind = kind def _infer_media_type(self) -> VideoMediaType: @@ -216,11 +233,18 @@ def __init__( self, url: str, force_download: bool = False, + identifier: str | None = None, vendor_metadata: dict[str, Any] | None = None, media_type: str | None = None, kind: Literal['audio-url'] = 'audio-url', ) -> None: - super().__init__(url=url, force_download=force_download, vendor_metadata=vendor_metadata, media_type=media_type) + super().__init__( + url=url, + force_download=force_download, + identifier=identifier, + vendor_metadata=vendor_metadata, + media_type=media_type, + ) self.kind = kind def _infer_media_type(self) -> AudioMediaType: @@ -266,11 +290,18 @@ def __init__( self, url: str, force_download: bool = False, + identifier: str | None = None, vendor_metadata: dict[str, Any] | None = None, media_type: str | None = None, kind: Literal['image-url'] = 'image-url', ) -> None: - super().__init__(url=url, force_download=force_download, vendor_metadata=vendor_metadata, media_type=media_type) + super().__init__( + url=url, + force_download=force_download, + identifier=identifier, + vendor_metadata=vendor_metadata, + media_type=media_type, + ) self.kind = kind def _infer_media_type(self) -> ImageMediaType: @@ -311,11 +342,18 @@ def __init__( self, url: str, force_download: bool = False, + identifier: str | None = None, vendor_metadata: dict[str, Any] | None = None, media_type: str | None = None, kind: Literal['document-url'] = 'document-url', ) -> None: - super().__init__(url=url, force_download=force_download, vendor_metadata=vendor_metadata, media_type=media_type) + super().__init__( + url=url, + force_download=force_download, + identifier=identifier, + vendor_metadata=vendor_metadata, + media_type=media_type, + ) self.kind = kind def _infer_media_type(self) -> str: diff --git a/tests/test_agent.py b/tests/test_agent.py index 25d1522a37..b331cb1ffc 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -28,7 +28,9 @@ from pydantic_ai.agent import AgentRunResult, WrapperAgent from pydantic_ai.messages import ( AgentStreamEvent, + AudioUrl, BinaryContent, + DocumentUrl, HandleResponseEvent, ImageUrl, ModelMessage, @@ -43,6 +45,7 @@ ToolReturn, ToolReturnPart, UserPromptPart, + VideoUrl, ) from pydantic_ai.models.function import AgentInfo, FunctionModel from pydantic_ai.models.test import TestModel @@ -3078,7 +3081,7 @@ def test_binary_content_serializable(): def test_image_url_serializable(): agent = Agent('test') - content = ImageUrl('https://example.com/chart', media_type='image/jpeg') + content = ImageUrl('https://example.com/chart', media_type='image/jpeg', identifier='url_id1') result = agent.run_sync(['Hello', content]) serialized = result.all_messages_json() @@ -3092,6 +3095,7 @@ def test_image_url_serializable(): { 'url': 'https://example.com/chart', 'force_download': False, + 'identifier': 'url_id1', 'vendor_metadata': None, 'kind': 'image-url', }, @@ -3216,6 +3220,72 @@ def get_image() -> BinaryContent: ) +def test_tool_returning_file_url_with_identifier(): + """Test that a tool returning FileUrl subclasses with identifiers works correctly.""" + + def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + if len(messages) == 1: + return ModelResponse(parts=[ToolCallPart('get_files', {})]) + else: + return ModelResponse(parts=[TextPart('Files received')]) + + agent = Agent(FunctionModel(llm)) + + @agent.tool_plain + def get_files(): + """Return various file URLs with custom identifiers.""" + from pydantic_ai.messages import AudioUrl, DocumentUrl, ImageUrl, VideoUrl + + return [ + ImageUrl(url='https://example.com/image.jpg', identifier='img_001'), + VideoUrl(url='https://example.com/video.mp4', identifier='vid_002'), + AudioUrl(url='https://example.com/audio.mp3', identifier='aud_003'), + DocumentUrl(url='https://example.com/document.pdf', identifier='doc_004'), + ] + + result = agent.run_sync('Get some files') + assert result.all_messages()[2] == snapshot( + ModelRequest( + parts=[ + ToolReturnPart( + tool_name='get_files', + content=['See file img_001', 'See file vid_002', 'See file aud_003', 'See file doc_004'], + tool_call_id=IsStr(), + timestamp=IsNow(tz=timezone.utc), + ), + UserPromptPart( + content=[ + 'This is file img_001:', + ImageUrl(url='https://example.com/image.jpg', identifier='img_001'), + ], + timestamp=IsNow(tz=timezone.utc), + ), + UserPromptPart( + content=[ + 'This is file vid_002:', + VideoUrl(url='https://example.com/video.mp4', identifier='vid_002'), + ], + timestamp=IsNow(tz=timezone.utc), + ), + UserPromptPart( + content=[ + 'This is file aud_003:', + AudioUrl(url='https://example.com/audio.mp3', identifier='aud_003'), + ], + timestamp=IsNow(tz=timezone.utc), + ), + UserPromptPart( + content=[ + 'This is file doc_004:', + DocumentUrl(url='https://example.com/document.pdf', identifier='doc_004'), + ], + timestamp=IsNow(tz=timezone.utc), + ), + ] + ) + ) + + def test_instructions_raise_error_when_system_prompt_is_set(): agent = Agent('test', instructions='An instructions!') From 39a402b5e982da6a87447fbbfd46fcc708da2c6a Mon Sep 17 00:00:00 2001 From: namkyu Date: Tue, 26 Aug 2025 22:45:54 +0900 Subject: [PATCH 02/15] generate identifier dynamically when not explicitly set --- pydantic_ai_slim/pydantic_ai/messages.py | 46 ++++++++++++++---------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/messages.py b/pydantic_ai_slim/pydantic_ai/messages.py index b22e4c041c..f384b088e0 100644 --- a/pydantic_ai_slim/pydantic_ai/messages.py +++ b/pydantic_ai_slim/pydantic_ai/messages.py @@ -1,6 +1,7 @@ from __future__ import annotations as _annotations import base64 +import hashlib from abc import ABC, abstractmethod from collections.abc import Sequence from dataclasses import dataclass, field, replace @@ -99,14 +100,6 @@ class FileUrl(ABC): * If False, the URL is sent directly to the model and no download is performed. """ - identifier: str | None = None - """Identifier for the file URL, such as a unique ID. - - This identifier can be provided to the model in a message to allow it to refer to this file in a tool call argument, and the tool can look up the file in question by iterating over the message history and finding the matching `FileUrl`. - - This identifier is only automatically passed to the model when the `FileUrl` is returned by a tool. If you're passing the `FileUrl` as a user message, it's up to you to include a separate text part with the identifier, e.g. "This is file :" preceding the `FileUrl`. - """ - vendor_metadata: dict[str, Any] | None = None """Vendor-specific metadata for the file. @@ -115,20 +108,37 @@ class FileUrl(ABC): """ _media_type: str | None = field(init=False, repr=False, compare=False) + _identifier: str | None = field(init=False, repr=False, compare=False) def __init__( self, url: str, force_download: bool = False, - identifier: str | None = None, vendor_metadata: dict[str, Any] | None = None, media_type: str | None = None, + identifier: str | None = None, ) -> None: self.url = url self.force_download = force_download - self.identifier = identifier self.vendor_metadata = vendor_metadata self._media_type = media_type + self._identifier = identifier + + @property + def identifier(self) -> str: + """Return the identifier of the file, generating one from the URL if not explicitly set. + + This identifier can be provided to the model in a message to allow it to refer to this file in a tool call argument, + and the tool can look up the file in question by iterating over the message history and finding the matching `FileUrl`. + + This identifier is only automatically passed to the model when the `FileUrl` is returned by a tool. + If you're passing the `FileUrl` as a user message, it's up to you to include a separate text part with the identifier, + e.g. "This is file :" preceding the `FileUrl`. + """ + if self._identifier is None: + url = self.url.encode('utf-8') + self._identifier = hashlib.sha1(url).hexdigest()[:6] + return self._identifier @property def media_type(self) -> str: @@ -163,17 +173,17 @@ def __init__( self, url: str, force_download: bool = False, - identifier: str | None = None, vendor_metadata: dict[str, Any] | None = None, media_type: str | None = None, kind: Literal['video-url'] = 'video-url', + identifier: str | None = None, ) -> None: super().__init__( url=url, force_download=force_download, - identifier=identifier, vendor_metadata=vendor_metadata, media_type=media_type, + identifier=identifier, ) self.kind = kind @@ -233,17 +243,17 @@ def __init__( self, url: str, force_download: bool = False, - identifier: str | None = None, vendor_metadata: dict[str, Any] | None = None, media_type: str | None = None, kind: Literal['audio-url'] = 'audio-url', + identifier: str | None = None, ) -> None: super().__init__( url=url, force_download=force_download, - identifier=identifier, vendor_metadata=vendor_metadata, media_type=media_type, + identifier=identifier, ) self.kind = kind @@ -290,17 +300,17 @@ def __init__( self, url: str, force_download: bool = False, - identifier: str | None = None, vendor_metadata: dict[str, Any] | None = None, media_type: str | None = None, kind: Literal['image-url'] = 'image-url', + identifier: str | None = None, ) -> None: super().__init__( url=url, force_download=force_download, - identifier=identifier, vendor_metadata=vendor_metadata, media_type=media_type, + identifier=identifier, ) self.kind = kind @@ -342,17 +352,17 @@ def __init__( self, url: str, force_download: bool = False, - identifier: str | None = None, vendor_metadata: dict[str, Any] | None = None, media_type: str | None = None, kind: Literal['document-url'] = 'document-url', + identifier: str | None = None, ) -> None: super().__init__( url=url, force_download=force_download, - identifier=identifier, vendor_metadata=vendor_metadata, media_type=media_type, + identifier=identifier, ) self.kind = kind From ad500b0e39bb13c64de08776237df2348a50c6f8 Mon Sep 17 00:00:00 2001 From: namkyu Date: Tue, 26 Aug 2025 23:49:00 +0900 Subject: [PATCH 03/15] remove identifier property from serialization test --- tests/test_agent.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_agent.py b/tests/test_agent.py index b331cb1ffc..d65087250a 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -3081,7 +3081,7 @@ def test_binary_content_serializable(): def test_image_url_serializable(): agent = Agent('test') - content = ImageUrl('https://example.com/chart', media_type='image/jpeg', identifier='url_id1') + content = ImageUrl('https://example.com/chart', media_type='image/jpeg') result = agent.run_sync(['Hello', content]) serialized = result.all_messages_json() @@ -3095,7 +3095,6 @@ def test_image_url_serializable(): { 'url': 'https://example.com/chart', 'force_download': False, - 'identifier': 'url_id1', 'vendor_metadata': None, 'kind': 'image-url', }, From 3211a2bee6565496ff6f4397cadd47eab486aee3 Mon Sep 17 00:00:00 2001 From: namkyu Date: Tue, 26 Aug 2025 23:50:24 +0900 Subject: [PATCH 04/15] update `multi_modal_content_identifier` to only accept bytes input from BinaryContent --- pydantic_ai_slim/pydantic_ai/_agent_graph.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/_agent_graph.py b/pydantic_ai_slim/pydantic_ai/_agent_graph.py index ddb2490e4b..274d761720 100644 --- a/pydantic_ai_slim/pydantic_ai/_agent_graph.py +++ b/pydantic_ai_slim/pydantic_ai/_agent_graph.py @@ -575,10 +575,8 @@ def build_run_context(ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT ) -def multi_modal_content_identifier(identifier: str | bytes) -> str: +def multi_modal_content_identifier(identifier: bytes) -> str: """Generate stable identifier for multi-modal content to help LLM in finding a specific file in tool call responses.""" - if isinstance(identifier, str): - identifier = identifier.encode('utf-8') return hashlib.sha1(identifier).hexdigest()[:6] From 1427dbf78c03717fb1db4317d9563e9e0a3c7d24 Mon Sep 17 00:00:00 2001 From: namkyu Date: Tue, 26 Aug 2025 23:51:12 +0900 Subject: [PATCH 05/15] remove dynamic generation of identifier for FileUrl content --- pydantic_ai_slim/pydantic_ai/_agent_graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/_agent_graph.py b/pydantic_ai_slim/pydantic_ai/_agent_graph.py index 274d761720..d7cbfb3277 100644 --- a/pydantic_ai_slim/pydantic_ai/_agent_graph.py +++ b/pydantic_ai_slim/pydantic_ai/_agent_graph.py @@ -767,7 +767,7 @@ def process_content(content: Any) -> Any: if isinstance(content, _messages.BinaryContent): identifier = content.identifier or multi_modal_content_identifier(content.data) else: - identifier = content.identifier or multi_modal_content_identifier(content.url) + identifier = content.identifier user_parts.append( _messages.UserPromptPart( From b80788ae6ea0f833b59e2bb9d0caf33c93571784 Mon Sep 17 00:00:00 2001 From: namkyu Date: Wed, 27 Aug 2025 21:03:19 +0900 Subject: [PATCH 06/15] refactor identifier handling for BinaryContent and FileUrl --- pydantic_ai_slim/pydantic_ai/_agent_graph.py | 15 +---- pydantic_ai_slim/pydantic_ai/messages.py | 66 +++++++++++++------- tests/test_agent.py | 11 ++-- 3 files changed, 49 insertions(+), 43 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/_agent_graph.py b/pydantic_ai_slim/pydantic_ai/_agent_graph.py index d7cbfb3277..85620c1fb4 100644 --- a/pydantic_ai_slim/pydantic_ai/_agent_graph.py +++ b/pydantic_ai_slim/pydantic_ai/_agent_graph.py @@ -2,7 +2,6 @@ import asyncio import dataclasses -import hashlib from collections import defaultdict, deque from collections.abc import AsyncIterator, Awaitable, Iterator, Sequence from contextlib import asynccontextmanager, contextmanager @@ -575,11 +574,6 @@ def build_run_context(ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT ) -def multi_modal_content_identifier(identifier: bytes) -> str: - """Generate stable identifier for multi-modal content to help LLM in finding a specific file in tool call responses.""" - return hashlib.sha1(identifier).hexdigest()[:6] - - async def process_function_tools( # noqa: C901 tool_manager: ToolManager[DepsT], tool_calls: list[_messages.ToolCallPart], @@ -764,18 +758,13 @@ def process_content(content: Any) -> Any: f'`ToolReturn` should be used directly.' ) elif isinstance(content, _messages.MultiModalContentTypes): - if isinstance(content, _messages.BinaryContent): - identifier = content.identifier or multi_modal_content_identifier(content.data) - else: - identifier = content.identifier - user_parts.append( _messages.UserPromptPart( - content=[f'This is file {identifier}:', content], + content=[f'This is file {content.identifier}:', content], part_kind='user-prompt', ) ) - return f'See file {identifier}' + return f'See file {content.identifier}' return content diff --git a/pydantic_ai_slim/pydantic_ai/messages.py b/pydantic_ai_slim/pydantic_ai/messages.py index f384b088e0..3ade2590b2 100644 --- a/pydantic_ai_slim/pydantic_ai/messages.py +++ b/pydantic_ai_slim/pydantic_ai/messages.py @@ -86,6 +86,13 @@ def otel_event(self, settings: InstrumentationSettings) -> Event: __repr__ = _utils.dataclasses_no_defaults_repr +def _multi_modal_content_identifier(identifier: str | bytes) -> str: + """Generate stable identifier for multi-modal content to help LLM in finding a specific file in tool call responses.""" + if isinstance(identifier, str): + identifier = identifier.encode('utf-8') + return hashlib.sha1(identifier).hexdigest()[:6] + + @dataclass(init=False, repr=False) class FileUrl(ABC): """Abstract base class for any URL-based file.""" @@ -93,6 +100,17 @@ class FileUrl(ABC): url: str """The URL of the file.""" + identifier: str + """The identifier of the file, such as a unique ID. generating one from the url if not explicitly set + + This identifier can be provided to the model in a message to allow it to refer to this file in a tool call argument, + and the tool can look up the file in question by iterating over the message history and finding the matching `FileUrl`. + + This identifier is only automatically passed to the model when the `FileUrl` is returned by a tool. + If you're passing the `FileUrl` as a user message, it's up to you to include a separate text part with the identifier, + e.g. "This is file :" preceding the `FileUrl`. + """ + force_download: bool = False """If the model supports it: @@ -108,7 +126,6 @@ class FileUrl(ABC): """ _media_type: str | None = field(init=False, repr=False, compare=False) - _identifier: str | None = field(init=False, repr=False, compare=False) def __init__( self, @@ -122,23 +139,7 @@ def __init__( self.force_download = force_download self.vendor_metadata = vendor_metadata self._media_type = media_type - self._identifier = identifier - - @property - def identifier(self) -> str: - """Return the identifier of the file, generating one from the URL if not explicitly set. - - This identifier can be provided to the model in a message to allow it to refer to this file in a tool call argument, - and the tool can look up the file in question by iterating over the message history and finding the matching `FileUrl`. - - This identifier is only automatically passed to the model when the `FileUrl` is returned by a tool. - If you're passing the `FileUrl` as a user message, it's up to you to include a separate text part with the identifier, - e.g. "This is file :" preceding the `FileUrl`. - """ - if self._identifier is None: - url = self.url.encode('utf-8') - self._identifier = hashlib.sha1(url).hexdigest()[:6] - return self._identifier + self.identifier = identifier or _multi_modal_content_identifier(url) @property def media_type(self) -> str: @@ -405,7 +406,7 @@ def format(self) -> DocumentFormat: raise ValueError(f'Unknown document media type: {media_type}') from e -@dataclass(repr=False) +@dataclass(init=False, repr=False) class BinaryContent: """Binary content, e.g. an audio or image file.""" @@ -415,12 +416,15 @@ class BinaryContent: media_type: AudioMediaType | ImageMediaType | DocumentMediaType | str """The media type of the binary data.""" - identifier: str | None = None - """Identifier for the binary content, such as a URL or unique ID. - - This identifier can be provided to the model in a message to allow it to refer to this file in a tool call argument, and the tool can look up the file in question by iterating over the message history and finding the matching `BinaryContent`. + identifier: str + """Identifier for the binary content, such as a unique ID. generating one from the data if not explicitly set + + This identifier can be provided to the model in a message to allow it to refer to this file in a tool call argument, + and the tool can look up the file in question by iterating over the message history and finding the matching `BinaryContent`. - This identifier is only automatically passed to the model when the `BinaryContent` is returned by a tool. If you're passing the `BinaryContent` as a user message, it's up to you to include a separate text part with the identifier, e.g. "This is file :" preceding the `BinaryContent`. + This identifier is only automatically passed to the model when the `BinaryContent` is returned by a tool. + If you're passing the `BinaryContent` as a user message, it's up to you to include a separate text part with the identifier, + e.g. "This is file :" preceding the `BinaryContent`. """ vendor_metadata: dict[str, Any] | None = None @@ -433,6 +437,20 @@ class BinaryContent: kind: Literal['binary'] = 'binary' """Type identifier, this is available on all parts as a discriminator.""" + def __init__( + self, + data: bytes, + media_type: AudioMediaType | ImageMediaType | DocumentMediaType | str, + identifier: str | None = None, + vendor_metadata: dict[str, Any] | None = None, + kind: Literal['binary'] = 'binary', + ) -> None: + self.data = data + self.media_type = media_type + self.identifier = identifier or _multi_modal_content_identifier(data) + self.vendor_metadata = vendor_metadata + self.kind = kind + @property def is_audio(self) -> bool: """Return `True` if the media type is an audio type.""" diff --git a/tests/test_agent.py b/tests/test_agent.py index d65087250a..25e083605f 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -3042,7 +3042,7 @@ def test_binary_content_serializable(): 'media_type': 'text/plain', 'vendor_metadata': None, 'kind': 'binary', - 'identifier': None, + 'identifier': 'f7ff9e', }, ], 'timestamp': IsStr(), @@ -3097,6 +3097,7 @@ def test_image_url_serializable(): 'force_download': False, 'vendor_metadata': None, 'kind': 'image-url', + 'identifier': 'bdd86d', }, ], 'timestamp': IsStr(), @@ -3135,7 +3136,7 @@ def test_image_url_serializable(): def test_tool_return_part_binary_content_serialization(): """Test that ToolReturnPart can properly serialize BinaryContent.""" 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', identifier='image_id_1') + 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') @@ -3144,12 +3145,12 @@ def test_tool_return_part_binary_content_serialization(): assert '"kind":"binary"' in response_str assert '"media_type":"image/png"' in response_str assert '"data":"' in response_str - assert '"identifier":"image_id_1"' in response_str + assert '"identifier":"14a01a"' in response_str response_obj = tool_return.model_response_object() assert response_obj['return_value']['kind'] == 'binary' assert response_obj['return_value']['media_type'] == 'image/png' - assert response_obj['return_value']['identifier'] == 'image_id_1' + assert response_obj['return_value']['identifier'] == '14a01a' assert 'data' in response_obj['return_value'] @@ -3233,8 +3234,6 @@ def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: @agent.tool_plain def get_files(): """Return various file URLs with custom identifiers.""" - from pydantic_ai.messages import AudioUrl, DocumentUrl, ImageUrl, VideoUrl - return [ ImageUrl(url='https://example.com/image.jpg', identifier='img_001'), VideoUrl(url='https://example.com/video.mp4', identifier='vid_002'), From ea2f0490a5b88bcd748ee8b4dfe56a000a526cc6 Mon Sep 17 00:00:00 2001 From: namkyu Date: Wed, 27 Aug 2025 21:11:50 +0900 Subject: [PATCH 07/15] reorder arguments in `Message` initializer for consistency --- pydantic_ai_slim/pydantic_ai/messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/messages.py b/pydantic_ai_slim/pydantic_ai/messages.py index 3ade2590b2..ec80d41704 100644 --- a/pydantic_ai_slim/pydantic_ai/messages.py +++ b/pydantic_ai_slim/pydantic_ai/messages.py @@ -251,8 +251,8 @@ def __init__( ) -> None: super().__init__( url=url, - force_download=force_download, vendor_metadata=vendor_metadata, + force_download=force_download, media_type=media_type, identifier=identifier, ) From f61233a83ed357551f7bc2f15876b068ba7c9210 Mon Sep 17 00:00:00 2001 From: namkyu Date: Wed, 27 Aug 2025 21:13:39 +0900 Subject: [PATCH 08/15] reorder arguments in `Message` initializer for consistency --- pydantic_ai_slim/pydantic_ai/messages.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/messages.py b/pydantic_ai_slim/pydantic_ai/messages.py index ec80d41704..51968d768c 100644 --- a/pydantic_ai_slim/pydantic_ai/messages.py +++ b/pydantic_ai_slim/pydantic_ai/messages.py @@ -130,8 +130,8 @@ class FileUrl(ABC): def __init__( self, url: str, - force_download: bool = False, vendor_metadata: dict[str, Any] | None = None, + force_download: bool = False, media_type: str | None = None, identifier: str | None = None, ) -> None: @@ -251,8 +251,8 @@ def __init__( ) -> None: super().__init__( url=url, - vendor_metadata=vendor_metadata, force_download=force_download, + vendor_metadata=vendor_metadata, media_type=media_type, identifier=identifier, ) From f2686de9bb2e9a7f9a0b335b3637acd4503c8a71 Mon Sep 17 00:00:00 2001 From: namkyu Date: Wed, 27 Aug 2025 21:15:48 +0900 Subject: [PATCH 09/15] reorder arguments in `Message` initializer for consistency --- pydantic_ai_slim/pydantic_ai/messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/messages.py b/pydantic_ai_slim/pydantic_ai/messages.py index 51968d768c..3ade2590b2 100644 --- a/pydantic_ai_slim/pydantic_ai/messages.py +++ b/pydantic_ai_slim/pydantic_ai/messages.py @@ -130,8 +130,8 @@ class FileUrl(ABC): def __init__( self, url: str, - vendor_metadata: dict[str, Any] | None = None, force_download: bool = False, + vendor_metadata: dict[str, Any] | None = None, media_type: str | None = None, identifier: str | None = None, ) -> None: From 0905d206a0ca54493e4deb1ed0e94a271943134e Mon Sep 17 00:00:00 2001 From: namkyu Date: Thu, 28 Aug 2025 09:32:29 +0900 Subject: [PATCH 10/15] remove unused HandleResponseEvent import --- tests/test_agent.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_agent.py b/tests/test_agent.py index 6cf5bb2752..237669db66 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -31,7 +31,6 @@ AudioUrl, BinaryContent, DocumentUrl, - HandleResponseEvent, ImageUrl, ModelMessage, ModelMessagesTypeAdapter, From 737b3bc2c63b1a521585ac881f3361f88a5f20c9 Mon Sep 17 00:00:00 2001 From: namkyu Date: Thu, 28 Aug 2025 09:33:14 +0900 Subject: [PATCH 11/15] add `identifier` field to updated test case --- tests/test_agent.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_agent.py b/tests/test_agent.py index 237669db66..2146594dd6 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -3096,6 +3096,7 @@ def test_image_url_serializable_missing_media_type(): 'vendor_metadata': None, 'kind': 'image-url', 'media_type': 'image/jpeg', + 'identifier': 'a72e39', }, ], 'timestamp': IsStr(), From 31f2035722d1f1ac2de00d513d1814b6977b19e6 Mon Sep 17 00:00:00 2001 From: namkyu Date: Thu, 28 Aug 2025 09:38:59 +0900 Subject: [PATCH 12/15] trim whitespace in `BinaryContent` docstring --- pydantic_ai_slim/pydantic_ai/messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/messages.py b/pydantic_ai_slim/pydantic_ai/messages.py index 9fb675895e..ed452fade7 100644 --- a/pydantic_ai_slim/pydantic_ai/messages.py +++ b/pydantic_ai_slim/pydantic_ai/messages.py @@ -437,7 +437,7 @@ class BinaryContent: identifier: str """Identifier for the binary content, such as a unique ID. generating one from the data if not explicitly set - + This identifier can be provided to the model in a message to allow it to refer to this file in a tool call argument, and the tool can look up the file in question by iterating over the message history and finding the matching `BinaryContent`. From 5e9b713372fdcc9bc1e657d249e1847158d04101 Mon Sep 17 00:00:00 2001 From: namkyu Date: Mon, 1 Sep 2025 23:09:54 +0900 Subject: [PATCH 13/15] trim whitespace in `messages.py` docstring --- pydantic_ai_slim/pydantic_ai/messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/messages.py b/pydantic_ai_slim/pydantic_ai/messages.py index 835fdd593b..4c237bb1c5 100644 --- a/pydantic_ai_slim/pydantic_ai/messages.py +++ b/pydantic_ai_slim/pydantic_ai/messages.py @@ -113,7 +113,7 @@ class FileUrl(ABC): If you're passing the `FileUrl` as a user message, it's up to you to include a separate text part with the identifier, e.g. "This is file :" preceding the `FileUrl`. """ - + _: KW_ONLY force_download: bool = False From 232c085fd48837395055a0bf2bc65d1e4e25c460 Mon Sep 17 00:00:00 2001 From: namkyu Date: Mon, 1 Sep 2025 23:12:28 +0900 Subject: [PATCH 14/15] fix update identifier test cases following UserPromptPart refactor --- tests/test_agent.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/tests/test_agent.py b/tests/test_agent.py index 28ad64795e..2b7965eb97 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -3372,25 +3372,10 @@ def get_files(): content=[ 'This is file img_001:', ImageUrl(url='https://example.com/image.jpg', identifier='img_001'), - ], - timestamp=IsNow(tz=timezone.utc), - ), - UserPromptPart( - content=[ 'This is file vid_002:', VideoUrl(url='https://example.com/video.mp4', identifier='vid_002'), - ], - timestamp=IsNow(tz=timezone.utc), - ), - UserPromptPart( - content=[ 'This is file aud_003:', AudioUrl(url='https://example.com/audio.mp3', identifier='aud_003'), - ], - timestamp=IsNow(tz=timezone.utc), - ), - UserPromptPart( - content=[ 'This is file doc_004:', DocumentUrl(url='https://example.com/document.pdf', identifier='doc_004'), ], From 92a2645fa32945e91da573ae33a201cdfb4b4c9f Mon Sep 17 00:00:00 2001 From: namkyu Date: Tue, 2 Sep 2025 09:04:37 +0900 Subject: [PATCH 15/15] add `*` to enforce keywords arguments for `MultiModalContent` types init --- pydantic_ai_slim/pydantic_ai/messages.py | 36 +++++++++++++----------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/messages.py b/pydantic_ai_slim/pydantic_ai/messages.py index 4c237bb1c5..c755885223 100644 --- a/pydantic_ai_slim/pydantic_ai/messages.py +++ b/pydantic_ai_slim/pydantic_ai/messages.py @@ -103,17 +103,6 @@ class FileUrl(ABC): url: str """The URL of the file.""" - identifier: str - """The identifier of the file, such as a unique ID. generating one from the url if not explicitly set - - This identifier can be provided to the model in a message to allow it to refer to this file in a tool call argument, - and the tool can look up the file in question by iterating over the message history and finding the matching `FileUrl`. - - This identifier is only automatically passed to the model when the `FileUrl` is returned by a tool. - If you're passing the `FileUrl` as a user message, it's up to you to include a separate text part with the identifier, - e.g. "This is file :" preceding the `FileUrl`. - """ - _: KW_ONLY force_download: bool = False @@ -134,9 +123,21 @@ class FileUrl(ABC): compare=False, default=None ) + identifier: str | None = None + """The identifier of the file, such as a unique ID. generating one from the url if not explicitly set + + This identifier can be provided to the model in a message to allow it to refer to this file in a tool call argument, + and the tool can look up the file in question by iterating over the message history and finding the matching `FileUrl`. + + This identifier is only automatically passed to the model when the `FileUrl` is returned by a tool. + If you're passing the `FileUrl` as a user message, it's up to you to include a separate text part with the identifier, + e.g. "This is file :" preceding the `FileUrl`. + """ + def __init__( self, url: str, + *, force_download: bool = False, vendor_metadata: dict[str, Any] | None = None, media_type: str | None = None, @@ -183,12 +184,12 @@ class VideoUrl(FileUrl): def __init__( self, url: str, + *, force_download: bool = False, vendor_metadata: dict[str, Any] | None = None, media_type: str | None = None, kind: Literal['video-url'] = 'video-url', identifier: str | None = None, - *, # Required for inline-snapshot which expects all dataclass `__init__` methods to take all field names as kwargs. _media_type: str | None = None, ) -> None: @@ -258,12 +259,12 @@ class AudioUrl(FileUrl): def __init__( self, url: str, + *, force_download: bool = False, vendor_metadata: dict[str, Any] | None = None, media_type: str | None = None, kind: Literal['audio-url'] = 'audio-url', identifier: str | None = None, - *, # Required for inline-snapshot which expects all dataclass `__init__` methods to take all field names as kwargs. _media_type: str | None = None, ) -> None: @@ -320,12 +321,12 @@ class ImageUrl(FileUrl): def __init__( self, url: str, + *, force_download: bool = False, vendor_metadata: dict[str, Any] | None = None, media_type: str | None = None, kind: Literal['image-url'] = 'image-url', identifier: str | None = None, - *, # Required for inline-snapshot which expects all dataclass `__init__` methods to take all field names as kwargs. _media_type: str | None = None, ) -> None: @@ -377,12 +378,12 @@ class DocumentUrl(FileUrl): def __init__( self, url: str, + *, force_download: bool = False, vendor_metadata: dict[str, Any] | None = None, media_type: str | None = None, kind: Literal['document-url'] = 'document-url', identifier: str | None = None, - *, # Required for inline-snapshot which expects all dataclass `__init__` methods to take all field names as kwargs. _media_type: str | None = None, ) -> None: @@ -441,6 +442,8 @@ class BinaryContent: data: bytes """The binary data.""" + _: KW_ONLY + media_type: AudioMediaType | ImageMediaType | DocumentMediaType | str """The media type of the binary data.""" @@ -454,8 +457,6 @@ class BinaryContent: e.g. "This is file :" preceding the `BinaryContent`. """ - _: KW_ONLY - vendor_metadata: dict[str, Any] | None = None """Vendor-specific metadata for the file. @@ -469,6 +470,7 @@ class BinaryContent: def __init__( self, data: bytes, + *, media_type: AudioMediaType | ImageMediaType | DocumentMediaType | str, identifier: str | None = None, vendor_metadata: dict[str, Any] | None = None,