Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
5793b44
Added support for text file for OpenAI using MagicDocumentUrl and Mag…
pulphix Sep 10, 2025
e60161e
Updated tests to fix Pyright errors
pulphix Sep 10, 2025
c7d258c
Fixed failing checks
pulphix Sep 10, 2025
544ae22
Fixed Errors on check pre commit
pulphix Sep 11, 2025
63edc4a
Fixed tests file based on pyright feedback
pulphix Sep 11, 2025
e98df12
Added 2 tests to cover Images with Magic Classes
pulphix Sep 11, 2025
8b00447
Fixed Pylint error
pulphix Sep 11, 2025
cf45451
Fixed missing space on import
pulphix Sep 11, 2025
f245f50
Removed pragma no cover on VideoUrl
pulphix Sep 11, 2025
4780ec2
Removed Magic Classes logic implemented directly on BinaryContent and…
pulphix Sep 12, 2025
69a322a
Removed filename and added url only for DocumentUrl
pulphix Sep 15, 2025
2caee86
Removed example file, Moved tests to test_openai and reduced to minim…
pulphix Sep 16, 2025
19e2ef4
Fixed Ruff Error
pulphix Sep 16, 2025
11c4c4f
Refactored tests not to use private method
pulphix Sep 16, 2025
e848cbf
Added 3 tests to Cover VideoUrl not supported Logic, Unsupported Medi…
pulphix Sep 16, 2025
9222b86
Removed # type ignore
pulphix Sep 16, 2025
b036b55
Fixed Format Issue
pulphix Sep 16, 2025
abb13ae
Reversed code as requested
pulphix Sep 16, 2025
c1cc80e
Rolled back identifier doc string
pulphix Sep 16, 2025
2f1f0d5
Simplified logic to be more readable
pulphix Sep 16, 2025
fb63178
Added # type: ignore[reportPrivateUsage]
pulphix Sep 16, 2025
c0c5775
Reverted file
pulphix Sep 16, 2025
25f0286
Merge branch 'main' into pulphix/implemented_text_file_support_for_op…
pulphix Sep 24, 2025
bdbc30c
Updated code based on review requests
pulphix Sep 24, 2025
cbe96ef
Updated _inline_file_block based on feedback
pulphix Sep 24, 2025
a471dec
Merge branch 'main' into pulphix/implemented_text_file_support_for_op…
pulphix Sep 26, 2025
b01f4fc
Updated based on requests
pulphix Sep 30, 2025
5fa678a
Merge branch 'pulphix/implemented_text_file_support_for_openai' of gi…
pulphix Sep 30, 2025
f248dc7
Merge commit 'f5602ddb4a7aa86b5c3e5a3ce53ae5ad7b2efc9a' into pulphix/…
pulphix Sep 30, 2025
c9d1be2
Merge commit 'f5602ddb4a7aa86b5c3e5a3ce53ae5ad7b2efc9a' into pulphix/…
pulphix Sep 30, 2025
3d7464a
Added assert identifier is not None
pulphix Sep 30, 2025
b6f0257
Added test_openai_map_user_prompt_video_url_raises_not_implemented
pulphix Sep 30, 2025
cd6458d
Added test_openai_map_user_prompt_video_url_raises_not_implemented
pulphix Sep 30, 2025
45bdd64
Fixed pylint error
pulphix Sep 30, 2025
d429f6c
Added test with updated cassette
pulphix Sep 30, 2025
8e85d92
Updated Dummy text file
pulphix Sep 30, 2025
224458e
Merge branch 'main' into pulphix/implemented_text_file_support_for_op…
pulphix Sep 30, 2025
d9d44f7
Merge branch 'main' into pulphix/implemented_text_file_support_for_op…
pulphix Sep 30, 2025
afede9d
Merge branch 'main' into pulphix/implemented_text_file_support_for_op…
pulphix Sep 30, 2025
85c69bb
Clean up
DouweM Sep 30, 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
3 changes: 3 additions & 0 deletions pydantic_ai_slim/pydantic_ai/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ class FileUrl(ABC):
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`.

It's also included in inline-text delimiters for providers that require inlining text content, so the model can
distinguish multiple files. If not provided, a short hash will be generated.

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 <identifier>:" preceding the `FileUrl`.
Expand Down
156 changes: 104 additions & 52 deletions pydantic_ai_slim/pydantic_ai/models/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -727,60 +727,112 @@ async def _map_user_message(self, message: ModelRequest) -> AsyncIterable[chat.C

@staticmethod
async def _map_user_prompt(part: UserPromptPart) -> chat.ChatCompletionUserMessageParam:
content: str | list[ChatCompletionContentPartParam]
if isinstance(part.content, str):
content = part.content
return chat.ChatCompletionUserMessageParam(role='user', content=part.content)
content_parts = await OpenAIChatModel._map_user_prompt_items(part.content)
return chat.ChatCompletionUserMessageParam(role='user', content=content_parts)

@staticmethod
async def _map_user_prompt_items(items: Sequence[object]) -> list[ChatCompletionContentPartParam]:
result: list[ChatCompletionContentPartParam] = []
for item in items:
result.extend(await OpenAIChatModel._map_single_item(item))
return result

@staticmethod
async def _map_single_item(item: object) -> list[ChatCompletionContentPartParam]:
if isinstance(item, str):
return [ChatCompletionContentPartTextParam(text=item, type='text')]
elif isinstance(item, ImageUrl):
return OpenAIChatModel._handle_image_url(item) or []
elif isinstance(item, BinaryContent):
return await OpenAIChatModel._handle_binary_content(item) or []
elif isinstance(item, AudioUrl):
return await OpenAIChatModel._handle_audio_url(item) or []
elif isinstance(item, DocumentUrl):
return await OpenAIChatModel._handle_document_url(item) or []
elif isinstance(item, VideoUrl):
raise NotImplementedError('VideoUrl is not supported for OpenAI')
else:
content = []
for item in part.content:
if isinstance(item, str):
content.append(ChatCompletionContentPartTextParam(text=item, type='text'))
elif isinstance(item, ImageUrl):
image_url = ImageURL(url=item.url)
content.append(ChatCompletionContentPartImageParam(image_url=image_url, type='image_url'))
elif isinstance(item, BinaryContent):
base64_encoded = base64.b64encode(item.data).decode('utf-8')
if item.is_image:
image_url = ImageURL(url=f'data:{item.media_type};base64,{base64_encoded}')
content.append(ChatCompletionContentPartImageParam(image_url=image_url, type='image_url'))
elif item.is_audio:
assert item.format in ('wav', 'mp3')
audio = InputAudio(data=base64_encoded, format=item.format)
content.append(ChatCompletionContentPartInputAudioParam(input_audio=audio, type='input_audio'))
elif item.is_document:
content.append(
File(
file=FileFile(
file_data=f'data:{item.media_type};base64,{base64_encoded}',
filename=f'filename.{item.format}',
),
type='file',
)
)
else: # pragma: no cover
raise RuntimeError(f'Unsupported binary content type: {item.media_type}')
elif isinstance(item, AudioUrl):
downloaded_item = await download_item(item, data_format='base64', type_format='extension')
assert downloaded_item['data_type'] in (
'wav',
'mp3',
), f'Unsupported audio format: {downloaded_item["data_type"]}'
audio = InputAudio(data=downloaded_item['data'], format=downloaded_item['data_type'])
content.append(ChatCompletionContentPartInputAudioParam(input_audio=audio, type='input_audio'))
elif isinstance(item, DocumentUrl):
downloaded_item = await download_item(item, data_format='base64_uri', type_format='extension')
file = File(
file=FileFile(
file_data=downloaded_item['data'], filename=f'filename.{downloaded_item["data_type"]}'
),
type='file',
)
content.append(file)
elif isinstance(item, VideoUrl): # pragma: no cover
raise NotImplementedError('VideoUrl is not supported for OpenAI')
else:
assert_never(item)
return chat.ChatCompletionUserMessageParam(role='user', content=content)
# Fallback: unknown type — return empty parts to avoid type-checker Never error
return []
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'd rather replace object with a more specific type hint, so we can use assert_never(item) here

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done


@staticmethod
def _handle_image_url(item: ImageUrl) -> list[ChatCompletionContentPartParam]:
image_url = ImageURL(url=item.url)
return [ChatCompletionContentPartImageParam(image_url=image_url, type='image_url')]

@staticmethod
async def _handle_binary_content(item: BinaryContent) -> list[ChatCompletionContentPartParam]:
if OpenAIChatModel._is_text_like_media_type(item.media_type):
# Inline text-like binary content as a text block
text = item.data.decode('utf-8')
media_type = item.media_type
inline = OpenAIChatModel._inline_file_block(media_type, text, identifier=item.identifier)
return [ChatCompletionContentPartTextParam(text=inline, type='text')]
base64_encoded = base64.b64encode(item.data).decode('utf-8')
if item.is_image:
image_url = ImageURL(url=f'data:{item.media_type};base64,{base64_encoded}')
return [ChatCompletionContentPartImageParam(image_url=image_url, type='image_url')]
if item.is_audio:
assert item.format in ('wav', 'mp3')
audio = InputAudio(data=base64_encoded, format=item.format)
return [ChatCompletionContentPartInputAudioParam(input_audio=audio, type='input_audio')]
if item.is_document:
return [
File(
file=FileFile(
file_data=f'data:{item.media_type};base64,{base64_encoded}',
filename=f'filename.{item.format}',
),
type='file',
)
]
return []

@staticmethod
async def _handle_audio_url(item: AudioUrl) -> list[ChatCompletionContentPartParam]:
downloaded_item = await download_item(item, data_format='base64', type_format='extension')
assert downloaded_item['data_type'] in ('wav', 'mp3'), (
f'Unsupported audio format: {downloaded_item["data_type"]}'
)
audio = InputAudio(data=downloaded_item['data'], format=downloaded_item['data_type'])
return [ChatCompletionContentPartInputAudioParam(input_audio=audio, type='input_audio')]

@staticmethod
async def _handle_document_url(item: DocumentUrl) -> list[ChatCompletionContentPartParam]:
if OpenAIChatModel._is_text_like_media_type(item.media_type):
downloaded_text = await download_item(item, data_format='text', type_format='extension')
inline = OpenAIChatModel._inline_file_block(
item.media_type, downloaded_text['data'], identifier=item.identifier
)
return [ChatCompletionContentPartTextParam(text=inline, type='text')]
downloaded_item = await download_item(item, data_format='base64_uri', type_format='extension')
return [
File(
file=FileFile(
file_data=downloaded_item['data'],
filename=f'filename.{downloaded_item["data_type"]}',
),
type='file',
)
]

@staticmethod
def _is_text_like_media_type(media_type: str) -> bool:
return (
media_type.startswith('text/')
or media_type == 'application/json'
or media_type.endswith('+json')
or media_type == 'application/xml'
or media_type.endswith('+xml')
or media_type in ('application/x-yaml', 'application/yaml')
)

@staticmethod
def _inline_file_block(media_type: str, text: str, identifier: str | None) -> str:
id_attr = f' id="{identifier}"' if identifier else ''
Copy link
Collaborator

Choose a reason for hiding this comment

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

Would there always be an identifier?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@DouweM based on the DocumentUrl object identifier can be None

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmm, I agree the type states it can be, but the implementation ensures it always has a value, as far as I can see. Can we add an assert identifier is not None in here, as there's not much use in giving the model an inline text part if there is no way to identify it?

return ''.join(['-----BEGIN FILE', id_attr, ' type="', media_type, '"-----\n', text, '\n-----END FILE-----'])
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
return ''.join(['-----BEGIN FILE', id_attr, ' type="', media_type, '"-----\n', text, '\n-----END FILE-----'])
return '\n'.join([
f'-----BEGIN FILE{id_attr} type="{media_type}"-----',
text,
f'-----END FILE {id_attr}-----',
])

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done



@deprecated(
Expand Down
160 changes: 160 additions & 0 deletions tests/models/test_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
ToolCallPart,
ToolReturnPart,
UserPromptPart,
VideoUrl,
)
from pydantic_ai.models import ModelRequestParameters
from pydantic_ai.output import NativeOutput, PromptedOutput, TextOutput, ToolOutput
Expand Down Expand Up @@ -822,6 +823,104 @@ async def test_document_url_input(allow_model_requests: None, openai_api_key: st
assert result.output == snapshot('The document contains the text "Dummy PDF file" on its single page.')


@pytest.mark.parametrize(
'media_type,body',
[
('text/plain', 'Hello'),
('application/json', '{"a":1}'),
('application/xml', '<a>1</a>'),
('application/yaml', 'a: 1'),
],
)
async def test_openai_binary_content_text_like_is_inlined(
media_type: str, body: str, openai_api_key: str, allow_model_requests: None
) -> None:
# Arrange input
bin_content = BinaryContent(data=body.encode(), media_type=media_type)
identifier = bin_content.identifier

# Capture mapped OpenAI messages via public request() API
captured: list[list[dict[str, Any]]] = []

async def fake_create(*args: Any, **kwargs: Any):
captured.append(kwargs['messages'])
raise RuntimeError('stop-after-capture')

model = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
# Monkeypatch the client's create method
model.client.chat.completions.create = fake_create
Copy link
Collaborator

Choose a reason for hiding this comment

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

In this particular case I think it's acceptable to directly call the private _map_user_message method (and add a # type: ignore[reportPrivateUsage] comment) as it's a lot easier to follow than this mocking.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done


# Act
with pytest.raises(RuntimeError, match='stop-after-capture'):
await model.request([ModelRequest(parts=[UserPromptPart(content=[bin_content])])], {}, ModelRequestParameters())

# Assert on the mapped user message content
user_msgs = captured[0]
# Find the user message
user = next(m for m in user_msgs if m.get('role') == 'user')
parts = cast(list[dict[str, Any]], user['content'])
assert parts[0]['type'] == 'text'
text = parts[0]['text']
assert text.startswith(f'-----BEGIN FILE id="{identifier}" type="{media_type}"-----')
assert text.rstrip().endswith('-----END FILE-----')


@pytest.mark.parametrize(
'url,media_type,data_type,body',
[
('https://example.com/file.txt', 'text/plain', 'txt', 'hello'),
('https://example.com/data.csv', 'text/csv', 'csv', 'a,b\n1,2'),
('https://example.com/data.json', 'application/json', 'json', '{"a":1}'),
('https://example.com/data.xml', 'application/xml', 'xml', '<a>1</a>'),
('https://example.com/readme.md', 'text/markdown', 'md', '# Title'),
('https://example.com/conf.yaml', 'application/yaml', 'yaml', 'a: 1'),
],
)
async def test_openai_document_url_text_like_is_inlined(
monkeypatch: pytest.MonkeyPatch,
url: str,
media_type: str,
data_type: str,
body: str,
openai_api_key: str,
allow_model_requests: None,
) -> None:
async def fake_download_item(
item: Any, data_format: str = 'text', type_format: str = 'extension'
) -> dict[str, str]:
assert data_format == 'text'
return {'data': body, 'data_type': data_type}

monkeypatch.setattr('pydantic_ai.models.openai.download_item', fake_download_item)

document_url = DocumentUrl(url=url, media_type=media_type)
identifier = document_url.identifier

captured: list[list[dict[str, Any]]] = []

async def fake_create(*args: Any, **kwargs: Any):
captured.append(kwargs['messages'])
raise RuntimeError('stop-after-capture')

model = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
model.client.chat.completions.create = fake_create

with pytest.raises(RuntimeError, match='stop-after-capture'):
await model.request(
[ModelRequest(parts=[UserPromptPart(content=[document_url])])],
{},
ModelRequestParameters(),
)

user_msgs = captured[0]
user = next(m for m in user_msgs if m.get('role') == 'user')
parts = cast(list[dict[str, Any]], user['content'])
assert parts[0]['type'] == 'text'
text = parts[0]['text']
assert text.startswith(f'-----BEGIN FILE id="{identifier}" type="{media_type}"-----')
assert text.rstrip().endswith('-----END FILE-----')
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we do a direct assert text == so that we can verify the newlines etc?



@pytest.mark.vcr()
async def test_image_url_tool_response(allow_model_requests: None, openai_api_key: str):
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
Expand Down Expand Up @@ -2929,3 +3028,64 @@ def test_deprecated_openai_model(openai_api_key: str):

provider = OpenAIProvider(api_key=openai_api_key)
OpenAIModel('gpt-4o', provider=provider) # type: ignore[reportDeprecated]


@pytest.mark.vcr()
async def test_openai_video_url_raises_not_implemented(openai_api_key: str, allow_model_requests: None) -> None:
model = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
with pytest.raises(NotImplementedError):
await model.request(
[ModelRequest(parts=[UserPromptPart(content=[VideoUrl(url='https://example.com/file.mp4')])])],
{},
ModelRequestParameters(),
)


async def test_openai_map_single_item_unknown_returns_empty_branch(
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 not sure why we need this test and the next one. What lines would be uncovered without them?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changed name of the test

openai_api_key: str, allow_model_requests: None
) -> None:
# Use BinaryContent with unsupported media_type to exercise empty mapping via public API

captured: list[list[dict[str, Any]]] = []

async def fake_create(*args: Any, **kwargs: Any):
captured.append(kwargs['messages'])
raise RuntimeError('stop-after-capture')

model = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
model.client.chat.completions.create = fake_create

bc = BinaryContent(data=b'data', media_type='application/octet-stream')
with pytest.raises(RuntimeError, match='stop-after-capture'):
await model.request([ModelRequest(parts=[UserPromptPart(content=[bc])])], {}, ModelRequestParameters())

user_msgs = captured[0]
user = next(m for m in user_msgs if m.get('role') == 'user')
parts = cast(list[dict[str, Any]], user['content'])
assert parts == []


async def test_openai_binary_content_unsupported_type(openai_api_key: str, allow_model_requests: None) -> None:
# Covers BinaryContent unsupported path (not text-like, not image/audio/document) via public API

captured: list[list[dict[str, Any]]] = []

async def fake_create(*args: Any, **kwargs: Any):
captured.append(kwargs['messages'])
raise RuntimeError('stop-after-capture')

model = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
model.client.chat.completions.create = fake_create

class Location(TypedDict):
city: str
country: str

unsupported = Location(city='Paris', country='France')
with pytest.raises(RuntimeError, match='stop-after-capture'):
await model.request([ModelRequest(parts=[UserPromptPart(content=[unsupported])])], {}, ModelRequestParameters()) # type: ignore[reportPrivateUsage]

user_msgs = captured[0]
user = next(m for m in user_msgs if m.get('role') == 'user')
parts = cast(list[dict[str, Any]], user['content'])
assert parts == []