Skip to content

Conversation

pulphix
Copy link
Contributor

@pulphix pulphix commented Sep 10, 2025

Problem addressed

OpenAI rejects text/plain as file parts; Anthropic/Gemini accept document URLs directly.
This created inconsistent DX.

New types

  • MagicDocumentUrl: subclass of DocumentUrl, adds optional filename and a magic marker.
  • MagicBinaryContent: subclass of BinaryContent, adds optional filename and a magic marker.

Both preserve their original types in serialized history so users can filter for them.

OpenAI-specific handling

  • If media_type == text/plain:
    • MagicDocumentUrl → downloaded as UTF-8 and converted to a single text UserContent with a clear delimiter:
      -----BEGIN FILE filename="<name>" type="text/plain"-----
      <file contents>
      -----END FILE-----
      
    • MagicBinaryContent → decoded as UTF-8 and converted to the same text format.
  • Non-text (PDF, images, etc.) → sent as OpenAI file parts (base64 + strict MIME + filename) like before.

Other providers

  • Anthropic/Gemini: Magic* are effectively pass-through (treated like their base classes), so PDFs/text URLs keep working without special casing.

Serialization/history

  • We keep the Magic* classes in the message history (with an is_magic marker) so users can filter by type even if OpenAI saw inline text at request time.

Tests

Added tests ensuring:

  • MagicBinaryContent (text/plain) → inline text with delimiter on OpenAI.
  • MagicBinaryContent (PDF) → file part on OpenAI.
  • MagicDocumentUrl (text/plain) → inline text with delimiter on OpenAI (mocked download).
  • MagicDocumentUrl (PDF) → file part on OpenAI (mocked download).

All functional tests pass; repo’s global 100% coverage target remains managed outside our changes.

Example

examples/pydantic_ai_examples/magic_files.py demonstrates both MagicDocumentUrl and MagicBinaryContent with OpenAI and Anthropic.
Loads API keys via python-dotenv if available (load_dotenv()).

How to use

Import:

from pydantic_ai import Agent, MagicDocumentUrl, MagicBinaryContent

@DouweM
Copy link
Collaborator

DouweM commented Sep 10, 2025

@pulphix Thanks Fabio, I'll hold off on reviewing this pending #1161 (comment).

@pulphix pulphix force-pushed the pulphix/implemented_text_file_support_for_openai branch from 0125d8a to 2ff0bfd Compare September 11, 2025 08:19
@DouweM
Copy link
Collaborator

DouweM commented Sep 11, 2025

@pulphix I discussed with @Kludex, and we'd the automatic behavior for text/* MIME type BinaryContents and DocumentUrls to be to inline them as text parts with HTTP multipart-style fencing. Can you please update the PR to remove the magic subclasses?

@pulphix pulphix force-pushed the pulphix/implemented_text_file_support_for_openai branch from 9883cd8 to affe38a Compare September 12, 2025 08:29
@pulphix
Copy link
Contributor Author

pulphix commented Sep 12, 2025

@DouweM @Kludex I updated the PR by removing the Magic Classes and porting the logic to BinaryContent and DocumentUrl.

I’m wondering whether it makes sense to include the file name in the BinaryContent prompt.
For example:

-----BEGIN FILE filename="file.xsl" type="application/xml"-----\n<a>1</a>\n-----END FILE-----

If multiple text files are uploaded as binary, they will all share the same name, which might create issues with LLM reasoning.
One option could be to use the identifier to make the file name unique.

@pulphix
Copy link
Contributor Author

pulphix commented Sep 12, 2025

Proposal

Add an optional filename field to both BinaryContent and DocumentUrl:

  • For BinaryContent, this allows specifying the original filename when encoding a file to base64
  • For DocumentUrl, this ensures that when a URL is resolved into a file, the adapter can preserve or supply a human-readable name.

@pulphix pulphix changed the title Added support for text file for OpenAI using MagicDocumentUrl and MagicBinaryContent Added support for text file for OpenAI on DocumentUrl and BinaryContent Sep 12, 2025
@pulphix pulphix force-pushed the pulphix/implemented_text_file_support_for_openai branch 2 times, most recently from 414cc86 to 83b8e15 Compare September 15, 2025 07:04
@pulphix
Copy link
Contributor Author

pulphix commented Sep 15, 2025

Removed reference to filename and added URL for DocumentUrl.

@pulphix pulphix force-pushed the pulphix/implemented_text_file_support_for_openai branch 2 times, most recently from 476cd9d to f7ef1ae Compare September 16, 2025 12:01
@pulphix pulphix requested a review from DouweM September 16, 2025 12:59
@pulphix pulphix force-pushed the pulphix/implemented_text_file_support_for_openai branch from 0e3214d to 9885418 Compare September 16, 2025 16:05
@pulphix
Copy link
Contributor Author

pulphix commented Sep 16, 2025

@DouweM I rebased from the main branch, but I encountered some failures.

@pulphix pulphix force-pushed the pulphix/implemented_text_file_support_for_openai branch from 9885418 to 5b60fc0 Compare September 16, 2025 18:43
@pulphix pulphix force-pushed the pulphix/implemented_text_file_support_for_openai branch from 5b60fc0 to c0c5775 Compare September 17, 2025 09:29
@staticmethod
def _inline_file_block(media_type: str, text: str, identifier: str | None) -> str:
id_attr = f' id="{identifier}"' if identifier else ''
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


@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?


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

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?

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

)


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

@pulphix
Copy link
Contributor Author

pulphix commented Sep 24, 2025

@DouweM Updated PR based on requests.

@pulphix pulphix requested a review from DouweM September 25, 2025 15:15

@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.

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?

@pulphix pulphix requested a review from DouweM September 30, 2025 20:02
@DouweM DouweM changed the title Added support for text file for OpenAI on DocumentUrl and BinaryContent Support text, JSON, XML and YAML DocumentUrl and BinaryContent on OpenAI Sep 30, 2025
@DouweM DouweM merged commit 5287abf into pydantic:main Sep 30, 2025
30 checks passed
@DouweM
Copy link
Collaborator

DouweM commented Sep 30, 2025

@pulphix Thanks for your work on this Fabio! I made a couple more code organization tweaks and will get this out in a release today.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Using BinaryContent with CSV media type throws an error
2 participants