OpenAI, Anthropic's Claude, and Google's Gemini can all process images and PDFs. The annoying part starts when an application needs to support more than one of them: each API expects a different request structure, different field names, and sometimes different capabilities.
This tutorial shows how to send images and PDFs to all three providers through the same Python interface. The examples use llm-api-adapter, a lightweight SDK that calls each provider directly and converts a shared UserMessage into the required wire format.
Repository: github.com/Inozem/llm_api_adapter
Install the library
pip install "llm-api-adapter>=0.5.1"
Version 0.5.1 or later is required for PDF input. Image input was introduced in 0.5.0.
The package does not require the OpenAI, Anthropic, or Google SDKs. It sends requests directly and uses requests as its only runtime dependency.
Configure a provider
Create one adapter for the provider and model you want to call:
import os
from llm_api_adapter.universal_adapter import UniversalLLMAPIAdapter
adapter = UniversalLLMAPIAdapter(
organization="openai",
model=os.environ["OPENAI_MODEL"],
api_key=os.environ["OPENAI_API_KEY"],
)
The file message examples below are independent from that configuration. To use Claude or Gemini, you create another adapter instance, but the UserMessage, ImagePart, and DocumentPart stay unchanged.
Send an image from a URL
The simplest option is an image with a public URL:
from llm_api_adapter.models.messages.chat_message import UserMessage
from llm_api_adapter.models.messages.file_parts import ImagePart
message = UserMessage(
"Describe this image in one sentence.",
files=[
ImagePart(
url="https://example.com/photo.jpg",
)
],
)
response = adapter.chat(
messages=[message],
max_tokens=200,
)
print(response.content)
ImagePart detects the MIME type from the URL extension. In this example, .jpg becomes image/jpeg.
Under the shared interface, the adapter converts this file into the format required by the selected provider. The application does not need separate OpenAI, Claude, and Gemini message builders.
Image URLs without an extension
Some image URLs do not end with a filename:
https://api.example.com/files/42
In that case, pass the MIME type explicitly:
image = ImagePart(
url="https://api.example.com/files/42",
media_type="image/jpeg",
)
This avoids guessing from response headers or downloading the file during message serialization.
Send an image from bytes
Use bytes when the image is local, generated by your application, stored behind authentication, or already available in memory:
from llm_api_adapter.models.messages.chat_message import UserMessage
from llm_api_adapter.models.messages.file_parts import ImagePart
with open("photo.png", "rb") as image_file:
image_bytes = image_file.read()
message = UserMessage(
"What is shown in this image?",
files=[
ImagePart(
data=image_bytes,
media_type="image/png",
)
],
)
response = adapter.chat(
messages=[message],
max_tokens=200,
)
print(response.content)
When you pass raw bytes, media_type is required. The adapter base64-encodes the data only when building the provider request.
Send multiple images in one message
The files field accepts a list, so the same interface works for comparisons and before-and-after analysis:
message = UserMessage(
"Compare these two images and list the visible differences.",
files=[
ImagePart(url="https://example.com/before.jpg"),
ImagePart(url="https://example.com/after.jpg"),
],
)
response = adapter.chat(
messages=[message],
max_tokens=400,
)
The text stays the first part of the user message, followed by the files in the order provided.
Send a PDF from bytes
PDF input uses DocumentPart. The rest of the request is identical to the image examples:
from llm_api_adapter.models.messages.chat_message import UserMessage
from llm_api_adapter.models.messages.file_parts import DocumentPart
with open("report.pdf", "rb") as pdf_file:
pdf_bytes = pdf_file.read()
message = UserMessage(
"Summarize this document in five bullet points.",
files=[
DocumentPart(
data=pdf_bytes,
media_type="application/pdf",
)
],
)
response = adapter.chat(
messages=[message],
max_tokens=600,
)
print(response.content)
PDF bytes provide the most portable document path. They work across the supported OpenAI, Anthropic, and Google request formats.
DocumentPart currently represents PDF input rather than every possible document format. This is intentional: a narrow validated contract is more useful than a generic FilePart that appears portable but fails differently for each provider.
Send a PDF from a URL
You can also create a document part from a URL:
message = UserMessage(
"Extract the main conclusions from this report.",
files=[
DocumentPart(
url="https://example.com/report.pdf",
)
],
)
DocumentPart detects application/pdf from a .pdf URL extension. For a URL without an extension, pass the MIME type explicitly:
document = DocumentPart(
url="https://api.example.com/files/42",
media_type="application/pdf",
)
PDF URL support has one important compatibility boundary:
- Anthropic accepts PDF URLs.
- Google accepts PDF URLs.
- OpenAI Responses accepts PDF URLs.
- OpenAI Chat Completions accepts PDF bytes, but not PDF URLs.
When the selected OpenAI path cannot represent a PDF URL, the adapter raises a clear ValueError instead of silently downloading the file, changing the endpoint, or sending an invalid payload.
For that path, download the file in your application under your own timeout, authentication, size, and security rules, then pass it as bytes.
Switch between OpenAI, Claude, and Gemini
The message is not tied to a provider:
message = UserMessage(
"Describe this image.",
files=[ImagePart(url="https://example.com/photo.jpg")],
)
Only the adapter configuration changes:
import os
from llm_api_adapter.universal_adapter import UniversalLLMAPIAdapter
adapters = {
"openai": UniversalLLMAPIAdapter(
organization="openai",
model=os.environ["OPENAI_MODEL"],
api_key=os.environ["OPENAI_API_KEY"],
),
"anthropic": UniversalLLMAPIAdapter(
organization="anthropic",
model=os.environ["ANTHROPIC_MODEL"],
api_key=os.environ["ANTHROPIC_API_KEY"],
),
"google": UniversalLLMAPIAdapter(
organization="google",
model=os.environ["GOOGLE_MODEL"],
api_key=os.environ["GOOGLE_API_KEY"],
),
}
provider = "anthropic"
response = adapters[provider].chat(
messages=[message],
max_tokens=200,
)
print(response.content)
You do not rebuild the image or document message when switching providers. This is the main boundary of the abstraction: normalize the portable application-facing input, then serialize it separately for each API.
Compatibility matrix
| Input | OpenAI | Claude | Gemini |
|---|---|---|---|
| Image URL | Yes | Yes | Yes |
| Image bytes | Yes | Yes | Yes |
| Multiple images | Yes | Yes | Yes |
| PDF bytes | Yes | Yes | Yes |
| PDF URL | Responses API only | Yes | Yes |
The exact model must also support the requested modality. A valid provider payload cannot make a text-only model accept an image or a document.
Common errors
Passing bytes without a MIME type
This is invalid:
ImagePart(data=image_bytes)
The adapter cannot safely infer a MIME type from arbitrary bytes. Pass it explicitly:
ImagePart(
data=image_bytes,
media_type="image/png",
)
The same rule applies to PDF bytes:
DocumentPart(
data=pdf_bytes,
media_type="application/pdf",
)
Providing both a URL and bytes
A file part must have one source:
ImagePart(
url="https://example.com/photo.jpg",
data=image_bytes,
media_type="image/jpeg",
)
This is ambiguous and raises an error. Use either url or data.
Using the wrong file class
ImagePart validates image/* MIME types. DocumentPart currently validates application/pdf.
This fails early:
ImagePart(
data=pdf_bytes,
media_type="application/pdf",
)
Early validation is preferable to waiting for a provider-specific HTTP error after the request has already been sent.
Sending a PDF URL through OpenAI Chat Completions
Use PDF bytes for that API path, or choose an OpenAI model routed through the Responses API. The adapter does not hide this difference because doing so would change application behavior.
What the adapter normalizes
The shared Python interface hides repetitive payload conversion:
- OpenAI uses different image and file blocks for Chat Completions and Responses.
- Anthropic uses image or document content blocks with URL or base64 sources.
- Google uses
fileDatafor URLs andinlineDatafor bytes.
The adapter converts ImagePart and DocumentPart into those structures. It does not pretend that every provider supports every source type through every endpoint.
That distinction matters. A useful multi-provider SDK should reduce provider-specific application code, but it should still expose real capability boundaries as explicit errors.
When this approach is useful
This interface is a good fit when:
- your application calls OpenAI, Claude, or Gemini directly;
- you want to change providers without rebuilding message objects;
- you need both URL and in-memory file input;
- you want a small SDK rather than an agent framework or hosted proxy;
- you prefer unsupported combinations to fail before the HTTP request.
A provider-native SDK may be a better choice when your application will never switch providers or depends heavily on a provider-specific file storage workflow.
Complete example
The following example sends a local PDF while keeping the provider configurable:
import os
from llm_api_adapter.models.messages.chat_message import UserMessage
from llm_api_adapter.models.messages.file_parts import DocumentPart
from llm_api_adapter.universal_adapter import UniversalLLMAPIAdapter
provider = os.environ.get("LLM_PROVIDER", "openai")
provider_config = {
"openai": {
"model": os.environ["OPENAI_MODEL"],
"api_key": os.environ["OPENAI_API_KEY"],
},
"anthropic": {
"model": os.environ["ANTHROPIC_MODEL"],
"api_key": os.environ["ANTHROPIC_API_KEY"],
},
"google": {
"model": os.environ["GOOGLE_MODEL"],
"api_key": os.environ["GOOGLE_API_KEY"],
},
}
with open("report.pdf", "rb") as pdf_file:
pdf_bytes = pdf_file.read()
message = UserMessage(
"Summarize this report in five bullet points.",
files=[
DocumentPart(
data=pdf_bytes,
media_type="application/pdf",
)
],
)
adapter = UniversalLLMAPIAdapter(
organization=provider,
**provider_config[provider],
)
response = adapter.chat(
messages=[message],
max_tokens=600,
)
print(response.content)
Change LLM_PROVIDER and the corresponding model and API key variables. The document message remains unchanged.
The goal is not to make three different APIs look identical. It is to keep application code stable where the capabilities are genuinely portable, and make the remaining differences explicit.
The source code is available in the repository:
github.com/Inozem/llm_api_adapter
Top comments (0)